citry-django-compressor 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citry_django_compressor-0.1.0/.gitignore +11 -0
- citry_django_compressor-0.1.0/LICENSE +21 -0
- citry_django_compressor-0.1.0/PKG-INFO +161 -0
- citry_django_compressor-0.1.0/README.md +130 -0
- citry_django_compressor-0.1.0/pyproject.toml +40 -0
- citry_django_compressor-0.1.0/src/citry_django_compressor/__init__.py +280 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joey Jurjens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: citry-django-compressor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Route Citry component assets through django-compressor for preprocessing and minification.
|
|
5
|
+
Project-URL: Repository, https://github.com/joeyjurjens/citry-django
|
|
6
|
+
Project-URL: Issues, https://github.com/joeyjurjens/citry-django/issues
|
|
7
|
+
Author-email: Joey Jurjens <joeyjurjens@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: assets,citry,compressor,django,precompiler,scss
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Framework :: Django :: 6.0
|
|
16
|
+
Classifier: Framework :: Django :: 6.1
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: citry-django
|
|
29
|
+
Requires-Dist: django-compressor>=4.0
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# citry-django-compressor
|
|
33
|
+
|
|
34
|
+
Route [Citry](https://citry.dev) component assets through [django-compressor](https://django-compressor.readthedocs.io/) for preprocessing and minification.
|
|
35
|
+
|
|
36
|
+
## Why?
|
|
37
|
+
|
|
38
|
+
Citry collects each component's CSS and JS and emits them into the page. If your project uses django-compressor for asset preprocessing (SCSS, Less, CoffeeScript, ...) and minification, this extension bridges the two.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install citry-django-compressor
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Register the extension with your Citry instance:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from citry import Citry
|
|
52
|
+
from citry_django import CitryDjangoExtension
|
|
53
|
+
from citry_django_compressor import CitryCompressorExtension
|
|
54
|
+
|
|
55
|
+
app = Citry(
|
|
56
|
+
extensions=[
|
|
57
|
+
CitryDjangoExtension(),
|
|
58
|
+
CitryCompressorExtension(),
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Inline Content with Precompilation
|
|
64
|
+
|
|
65
|
+
To mark inline content for precompilation, set its `type` attribute to match a `COMPRESS_PRECOMPILERS` entry:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from citry import Component
|
|
69
|
+
from citry.ext.dependencies import Style, Script
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MyComponent(Component):
|
|
73
|
+
class Dependencies:
|
|
74
|
+
css = [
|
|
75
|
+
Style(content="...", attrs={"type": "text/x-scss"}),
|
|
76
|
+
]
|
|
77
|
+
js = [
|
|
78
|
+
Script(content="square = (x) -> x * x", attrs={"type": "text/coffeescript"}),
|
|
79
|
+
]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### File-Based Assets
|
|
83
|
+
|
|
84
|
+
For file-based assets, use the `Dependencies` class with a URL and `type` attribute. Use Django's `static()` to respect your `STATIC_URL` setting:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from django.templatetags.static import static
|
|
88
|
+
from citry import Component
|
|
89
|
+
from citry.ext.dependencies import Style
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MyComponent(Component):
|
|
93
|
+
class Dependencies:
|
|
94
|
+
css = [
|
|
95
|
+
Style(url=static("component.scss"), attrs={"type": "text/x-scss"}),
|
|
96
|
+
]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Django-compressor will find the file via staticfiles, precompile it, and output a compressed URL.
|
|
100
|
+
|
|
101
|
+
### Django Settings
|
|
102
|
+
|
|
103
|
+
Configure django-compressor as usual:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
INSTALLED_APPS = [
|
|
107
|
+
# ...
|
|
108
|
+
"compressor",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
STATICFILES_FINDERS = [
|
|
112
|
+
# ...
|
|
113
|
+
"compressor.finders.CompressorFinder",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
COMPRESS_PRECOMPILERS = (
|
|
117
|
+
("text/x-scss", "django_libsass.SassCompiler"),
|
|
118
|
+
("text/x-sass", "django_libsass.SassCompiler"),
|
|
119
|
+
("text/less", "lessc {infile} {outfile}"),
|
|
120
|
+
("text/coffeescript", "coffee --compile --stdio"),
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Custom File Type Mapping
|
|
125
|
+
|
|
126
|
+
By default, the extension maps these file extensions to MIME types:
|
|
127
|
+
|
|
128
|
+
| Extension | MIME Type |
|
|
129
|
+
|-----------|-----------|
|
|
130
|
+
| `.scss` | `text/x-scss` |
|
|
131
|
+
| `.sass` | `text/x-sass` |
|
|
132
|
+
| `.less` | `text/less` |
|
|
133
|
+
| `.styl` | `text/stylus` |
|
|
134
|
+
| `.coffee` | `text/coffeescript` |
|
|
135
|
+
|
|
136
|
+
Extend or override with `CITRY_COMPRESSOR_FILE_TYPES`:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
CITRY_COMPRESSOR_FILE_TYPES = {
|
|
140
|
+
".myformat": "text/x-myformat",
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## How It Works
|
|
145
|
+
|
|
146
|
+
1. Citry collects each component's CSS and JS during rendering
|
|
147
|
+
2. Citry deduplicates identical assets (same content or URL)
|
|
148
|
+
3. Before emitting, the extension's `on_dependencies` hook fires
|
|
149
|
+
4. Assets with precompiler `type` attributes are collected
|
|
150
|
+
5. They're fed to django-compressor's programmatic API
|
|
151
|
+
6. The original dependencies are replaced with URL-based ones pointing to compressed output
|
|
152
|
+
|
|
153
|
+
Citry's `$component()` callbacks and `js_data()` work normally - they're just JavaScript content that passes through compression unchanged.
|
|
154
|
+
|
|
155
|
+
## Limitations
|
|
156
|
+
|
|
157
|
+
- **`css_file` / `js_file`**: When using Citry's `css_file = "component.scss"`, the file content is inlined and the filename is not preserved. The extension cannot detect the file type automatically. Use the `Dependencies` class with explicit `type` attribute instead.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# citry-django-compressor
|
|
2
|
+
|
|
3
|
+
Route [Citry](https://citry.dev) component assets through [django-compressor](https://django-compressor.readthedocs.io/) for preprocessing and minification.
|
|
4
|
+
|
|
5
|
+
## Why?
|
|
6
|
+
|
|
7
|
+
Citry collects each component's CSS and JS and emits them into the page. If your project uses django-compressor for asset preprocessing (SCSS, Less, CoffeeScript, ...) and minification, this extension bridges the two.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install citry-django-compressor
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Register the extension with your Citry instance:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from citry import Citry
|
|
21
|
+
from citry_django import CitryDjangoExtension
|
|
22
|
+
from citry_django_compressor import CitryCompressorExtension
|
|
23
|
+
|
|
24
|
+
app = Citry(
|
|
25
|
+
extensions=[
|
|
26
|
+
CitryDjangoExtension(),
|
|
27
|
+
CitryCompressorExtension(),
|
|
28
|
+
]
|
|
29
|
+
)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Inline Content with Precompilation
|
|
33
|
+
|
|
34
|
+
To mark inline content for precompilation, set its `type` attribute to match a `COMPRESS_PRECOMPILERS` entry:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from citry import Component
|
|
38
|
+
from citry.ext.dependencies import Style, Script
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MyComponent(Component):
|
|
42
|
+
class Dependencies:
|
|
43
|
+
css = [
|
|
44
|
+
Style(content="...", attrs={"type": "text/x-scss"}),
|
|
45
|
+
]
|
|
46
|
+
js = [
|
|
47
|
+
Script(content="square = (x) -> x * x", attrs={"type": "text/coffeescript"}),
|
|
48
|
+
]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### File-Based Assets
|
|
52
|
+
|
|
53
|
+
For file-based assets, use the `Dependencies` class with a URL and `type` attribute. Use Django's `static()` to respect your `STATIC_URL` setting:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from django.templatetags.static import static
|
|
57
|
+
from citry import Component
|
|
58
|
+
from citry.ext.dependencies import Style
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MyComponent(Component):
|
|
62
|
+
class Dependencies:
|
|
63
|
+
css = [
|
|
64
|
+
Style(url=static("component.scss"), attrs={"type": "text/x-scss"}),
|
|
65
|
+
]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Django-compressor will find the file via staticfiles, precompile it, and output a compressed URL.
|
|
69
|
+
|
|
70
|
+
### Django Settings
|
|
71
|
+
|
|
72
|
+
Configure django-compressor as usual:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
INSTALLED_APPS = [
|
|
76
|
+
# ...
|
|
77
|
+
"compressor",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
STATICFILES_FINDERS = [
|
|
81
|
+
# ...
|
|
82
|
+
"compressor.finders.CompressorFinder",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
COMPRESS_PRECOMPILERS = (
|
|
86
|
+
("text/x-scss", "django_libsass.SassCompiler"),
|
|
87
|
+
("text/x-sass", "django_libsass.SassCompiler"),
|
|
88
|
+
("text/less", "lessc {infile} {outfile}"),
|
|
89
|
+
("text/coffeescript", "coffee --compile --stdio"),
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Custom File Type Mapping
|
|
94
|
+
|
|
95
|
+
By default, the extension maps these file extensions to MIME types:
|
|
96
|
+
|
|
97
|
+
| Extension | MIME Type |
|
|
98
|
+
|-----------|-----------|
|
|
99
|
+
| `.scss` | `text/x-scss` |
|
|
100
|
+
| `.sass` | `text/x-sass` |
|
|
101
|
+
| `.less` | `text/less` |
|
|
102
|
+
| `.styl` | `text/stylus` |
|
|
103
|
+
| `.coffee` | `text/coffeescript` |
|
|
104
|
+
|
|
105
|
+
Extend or override with `CITRY_COMPRESSOR_FILE_TYPES`:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
CITRY_COMPRESSOR_FILE_TYPES = {
|
|
109
|
+
".myformat": "text/x-myformat",
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## How It Works
|
|
114
|
+
|
|
115
|
+
1. Citry collects each component's CSS and JS during rendering
|
|
116
|
+
2. Citry deduplicates identical assets (same content or URL)
|
|
117
|
+
3. Before emitting, the extension's `on_dependencies` hook fires
|
|
118
|
+
4. Assets with precompiler `type` attributes are collected
|
|
119
|
+
5. They're fed to django-compressor's programmatic API
|
|
120
|
+
6. The original dependencies are replaced with URL-based ones pointing to compressed output
|
|
121
|
+
|
|
122
|
+
Citry's `$component()` callbacks and `js_data()` work normally - they're just JavaScript content that passes through compression unchanged.
|
|
123
|
+
|
|
124
|
+
## Limitations
|
|
125
|
+
|
|
126
|
+
- **`css_file` / `js_file`**: When using Citry's `css_file = "component.scss"`, the file content is inlined and the filename is not preserved. The extension cannot detect the file type automatically. Use the `Dependencies` class with explicit `type` attribute instead.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "citry-django-compressor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Route Citry component assets through django-compressor for preprocessing and minification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Joey Jurjens", email = "joeyjurjens@gmail.com" }]
|
|
14
|
+
keywords = ["citry", "django", "compressor", "assets", "scss", "precompiler"]
|
|
15
|
+
dependencies = ["citry-django", "django-compressor>=4.0"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Web Environment",
|
|
19
|
+
"Framework :: Django",
|
|
20
|
+
"Framework :: Django :: 5.2",
|
|
21
|
+
"Framework :: Django :: 6.0",
|
|
22
|
+
"Framework :: Django :: 6.1",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Programming Language :: Python :: 3.14",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/joeyjurjens/citry-django"
|
|
37
|
+
Issues = "https://github.com/joeyjurjens/citry-django/issues"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/citry_django_compressor"]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Route Citry component assets through django-compressor.
|
|
3
|
+
|
|
4
|
+
Citry collects each component's CSS and JS and emits them into the page.
|
|
5
|
+
A project that runs django-compressor wants those assets preprocessed
|
|
6
|
+
(SCSS, Less, CoffeeScript, ...) and minified before they reach the browser.
|
|
7
|
+
|
|
8
|
+
This extension hooks Citry's ``on_dependencies`` lifecycle to feed assets
|
|
9
|
+
into django-compressor's programmatic API, replacing inline content with
|
|
10
|
+
compressed file URLs.
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
from citry import Citry
|
|
15
|
+
from citry_django import CitryDjangoExtension
|
|
16
|
+
from citry_django_compressor import CitryCompressorExtension
|
|
17
|
+
|
|
18
|
+
app = Citry(extensions=[CitryDjangoExtension(), CitryCompressorExtension()])
|
|
19
|
+
|
|
20
|
+
Components declare assets the usual Citry way. To mark an asset for
|
|
21
|
+
precompilation, set its ``type`` attribute to match a
|
|
22
|
+
``COMPRESS_PRECOMPILERS`` entry::
|
|
23
|
+
|
|
24
|
+
from citry.ext.dependencies import Style
|
|
25
|
+
|
|
26
|
+
class MyComponent(Component):
|
|
27
|
+
class Dependencies:
|
|
28
|
+
css = [Style(content="...", attrs={"type": "text/x-scss"})]
|
|
29
|
+
|
|
30
|
+
For file-based assets, use the ``Dependencies`` class with a URL and type.
|
|
31
|
+
Use Django's ``static()`` to respect your ``STATIC_URL`` setting::
|
|
32
|
+
|
|
33
|
+
from django.templatetags.static import static
|
|
34
|
+
|
|
35
|
+
class MyComponent(Component):
|
|
36
|
+
class Dependencies:
|
|
37
|
+
css = [Style(url=static("component.scss"), attrs={"type": "text/x-scss"})]
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import re
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from citry.ext.dependencies import Script, Style
|
|
46
|
+
from citry.extension import Extension
|
|
47
|
+
from compressor.css import CssCompressor
|
|
48
|
+
from compressor.js import JsCompressor
|
|
49
|
+
|
|
50
|
+
__all__ = ["CitryCompressorExtension"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# File extensions that map to precompiler MIME types.
|
|
54
|
+
# Users can extend this via CITRY_COMPRESSOR_FILE_TYPES setting.
|
|
55
|
+
DEFAULT_FILE_TYPES: dict[str, str] = {
|
|
56
|
+
# CSS precompilers
|
|
57
|
+
".scss": "text/x-scss",
|
|
58
|
+
".sass": "text/x-sass",
|
|
59
|
+
".less": "text/less",
|
|
60
|
+
".styl": "text/stylus",
|
|
61
|
+
# JS precompilers
|
|
62
|
+
".coffee": "text/coffeescript",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Standard types that don't need precompilation.
|
|
66
|
+
STANDARD_TYPES = frozenset({"text/css", "text/javascript", "module"})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_mimetype_from_url(url: str, file_types: dict[str, str]) -> str | None:
|
|
70
|
+
"""Extract MIME type from URL based on file extension."""
|
|
71
|
+
for ext, mimetype in file_types.items():
|
|
72
|
+
if url.endswith(ext):
|
|
73
|
+
return mimetype
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _needs_precompilation(dep: Script | Style, file_types: dict[str, str]) -> bool:
|
|
78
|
+
"""Check if a dependency needs precompilation."""
|
|
79
|
+
type_attr = dep.attrs.get("type")
|
|
80
|
+
if type_attr and isinstance(type_attr, str) and type_attr not in STANDARD_TYPES:
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
if dep.url:
|
|
84
|
+
return _get_mimetype_from_url(dep.url, file_types) is not None
|
|
85
|
+
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_compressor_content(deps: list[Script | Style], kind: str) -> str:
|
|
90
|
+
"""Build HTML content string for django-compressor."""
|
|
91
|
+
parts = []
|
|
92
|
+
for dep in deps:
|
|
93
|
+
if kind == "css":
|
|
94
|
+
if dep.url:
|
|
95
|
+
attrs_str = " ".join(f'{k}="{v}"' for k, v in dep.attrs.items() if k != "type")
|
|
96
|
+
type_attr = dep.attrs.get("type", "")
|
|
97
|
+
type_str = f' type="{type_attr}"' if type_attr else ""
|
|
98
|
+
parts.append(f'<link rel="stylesheet" href="{dep.url}"{type_str}{attrs_str}/>')
|
|
99
|
+
else:
|
|
100
|
+
attrs_str = " ".join(f'{k}="{v}"' for k, v in dep.attrs.items() if k != "type")
|
|
101
|
+
type_attr = dep.attrs.get("type", "")
|
|
102
|
+
type_str = f' type="{type_attr}"' if type_attr else ""
|
|
103
|
+
attrs_prefix = f" {attrs_str}" if attrs_str else ""
|
|
104
|
+
parts.append(f"<style{type_str}{attrs_prefix}>{dep.content}</style>")
|
|
105
|
+
else: # js
|
|
106
|
+
if dep.url:
|
|
107
|
+
attrs_str = " ".join(f'{k}="{v}"' for k, v in dep.attrs.items() if k != "type")
|
|
108
|
+
type_attr = dep.attrs.get("type", "")
|
|
109
|
+
type_str = f' type="{type_attr}"' if type_attr else ""
|
|
110
|
+
parts.append(f'<script src="{dep.url}"{type_str}{attrs_str}></script>')
|
|
111
|
+
else:
|
|
112
|
+
attrs_str = " ".join(f'{k}="{v}"' for k, v in dep.attrs.items() if k != "type")
|
|
113
|
+
type_attr = dep.attrs.get("type", "")
|
|
114
|
+
type_str = f' type="{type_attr}"' if type_attr else ""
|
|
115
|
+
attrs_prefix = f" {attrs_str}" if attrs_str else ""
|
|
116
|
+
parts.append(f"<script{type_str}{attrs_prefix}>{dep.content}</script>")
|
|
117
|
+
return "\n".join(parts)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _extract_urls_from_output(html: str, kind: str) -> list[dict[str, Any]]:
|
|
121
|
+
"""
|
|
122
|
+
Extract URLs and attributes from compressor output HTML.
|
|
123
|
+
|
|
124
|
+
Returns list of dicts with 'url' or 'content' and optional 'attrs'.
|
|
125
|
+
"""
|
|
126
|
+
results = []
|
|
127
|
+
|
|
128
|
+
if kind == "css":
|
|
129
|
+
# Parse <link> tags
|
|
130
|
+
for match in re.finditer(r'<link[^>]*href="([^"]+)"[^>]*/?>', html, re.IGNORECASE):
|
|
131
|
+
url = match.group(1)
|
|
132
|
+
attrs = {}
|
|
133
|
+
tag_str = match.group(0)
|
|
134
|
+
media_match = re.search(r'media="([^"]+)"', tag_str, re.IGNORECASE)
|
|
135
|
+
if media_match:
|
|
136
|
+
attrs["media"] = media_match.group(1)
|
|
137
|
+
results.append({"url": url, "attrs": attrs})
|
|
138
|
+
|
|
139
|
+
# Parse inline <style> tags (when compression is disabled)
|
|
140
|
+
for match in re.finditer(r"<style[^>]*>(.*?)</style>", html, re.IGNORECASE | re.DOTALL):
|
|
141
|
+
content = match.group(1)
|
|
142
|
+
tag_str = match.group(0)
|
|
143
|
+
attrs = {}
|
|
144
|
+
media_match = re.search(r'media="([^"]+)"', tag_str, re.IGNORECASE)
|
|
145
|
+
if media_match:
|
|
146
|
+
attrs["media"] = media_match.group(1)
|
|
147
|
+
results.append({"content": content, "attrs": attrs})
|
|
148
|
+
else: # js
|
|
149
|
+
# Parse <script> tags with src
|
|
150
|
+
for match in re.finditer(r'<script[^>]*src="([^"]+)"[^>]*>', html, re.IGNORECASE):
|
|
151
|
+
url = match.group(1)
|
|
152
|
+
tag_str = match.group(0)
|
|
153
|
+
attrs = {}
|
|
154
|
+
if re.search(r"\bdefer\b", tag_str, re.IGNORECASE):
|
|
155
|
+
attrs["defer"] = True
|
|
156
|
+
if re.search(r"\basync\b", tag_str, re.IGNORECASE):
|
|
157
|
+
attrs["async"] = True
|
|
158
|
+
results.append({"url": url, "attrs": attrs})
|
|
159
|
+
|
|
160
|
+
# Parse inline <script> tags (when compression is disabled)
|
|
161
|
+
for match in re.finditer(r"<script([^>]*)>(.*?)</script>", html, re.IGNORECASE | re.DOTALL):
|
|
162
|
+
attrs_str = match.group(1)
|
|
163
|
+
content = match.group(2)
|
|
164
|
+
if re.search(r'src="', attrs_str, re.IGNORECASE):
|
|
165
|
+
continue
|
|
166
|
+
attrs = {}
|
|
167
|
+
if re.search(r"\bdefer\b", attrs_str, re.IGNORECASE):
|
|
168
|
+
attrs["defer"] = True
|
|
169
|
+
if re.search(r"\basync\b", attrs_str, re.IGNORECASE):
|
|
170
|
+
attrs["async"] = True
|
|
171
|
+
results.append({"content": content, "attrs": attrs})
|
|
172
|
+
|
|
173
|
+
return results
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CitryCompressorExtension(Extension):
|
|
177
|
+
"""
|
|
178
|
+
Routes Citry component assets through django-compressor.
|
|
179
|
+
|
|
180
|
+
Assets marked with a precompiler ``type`` attribute are fed to
|
|
181
|
+
django-compressor, which preprocesses (SCSS, Less, etc.) and minifies
|
|
182
|
+
them. The original dependencies are replaced with URL-based ones
|
|
183
|
+
pointing to the compressed output.
|
|
184
|
+
|
|
185
|
+
Citry's deduplication runs before this hook, so identical assets from
|
|
186
|
+
multiple components are only compressed once.
|
|
187
|
+
|
|
188
|
+
For file-based assets, use the ``Dependencies`` class with a URL and
|
|
189
|
+
explicit ``type`` attribute. The extension detects the file type from
|
|
190
|
+
the URL extension or the ``type`` attribute.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
name = "compressor"
|
|
194
|
+
|
|
195
|
+
def __init__(self) -> None:
|
|
196
|
+
self._file_types: dict[str, str] | None = None
|
|
197
|
+
|
|
198
|
+
def _get_file_types(self) -> dict[str, str]:
|
|
199
|
+
"""Get file extension to MIME type mapping, with user overrides."""
|
|
200
|
+
if self._file_types is None:
|
|
201
|
+
from django.conf import settings
|
|
202
|
+
|
|
203
|
+
user_types = getattr(settings, "CITRY_COMPRESSOR_FILE_TYPES", {})
|
|
204
|
+
self._file_types = {**DEFAULT_FILE_TYPES, **user_types}
|
|
205
|
+
return self._file_types
|
|
206
|
+
|
|
207
|
+
def on_dependencies(self, ctx: Any) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Hook into Citry's dependency emission to compress assets.
|
|
210
|
+
|
|
211
|
+
Citry has already deduplicated assets by this point, so we only
|
|
212
|
+
see each unique asset once. We collect assets that need
|
|
213
|
+
precompilation, feed them to django-compressor, and replace the
|
|
214
|
+
originals with compressed URLs.
|
|
215
|
+
"""
|
|
216
|
+
file_types = self._get_file_types()
|
|
217
|
+
|
|
218
|
+
css_to_compress: list[Style] = []
|
|
219
|
+
css_passthrough: list[Style] = []
|
|
220
|
+
js_to_compress: list[Script] = []
|
|
221
|
+
js_passthrough: list[Script] = []
|
|
222
|
+
|
|
223
|
+
for style in ctx.styles:
|
|
224
|
+
if _needs_precompilation(style, file_types):
|
|
225
|
+
css_to_compress.append(style)
|
|
226
|
+
else:
|
|
227
|
+
css_passthrough.append(style)
|
|
228
|
+
|
|
229
|
+
for script in ctx.scripts:
|
|
230
|
+
# Skip core scripts (Citry runtime, manifest) - already optimized
|
|
231
|
+
if script.kind == "core":
|
|
232
|
+
js_passthrough.append(script)
|
|
233
|
+
elif _needs_precompilation(script, file_types):
|
|
234
|
+
js_to_compress.append(script)
|
|
235
|
+
else:
|
|
236
|
+
js_passthrough.append(script)
|
|
237
|
+
|
|
238
|
+
if css_to_compress:
|
|
239
|
+
compressed_css = self._compress_css(css_to_compress)
|
|
240
|
+
ctx.styles[:] = css_passthrough + compressed_css
|
|
241
|
+
|
|
242
|
+
if js_to_compress:
|
|
243
|
+
compressed_js = self._compress_js(js_to_compress)
|
|
244
|
+
ctx.scripts[:] = js_passthrough + compressed_js
|
|
245
|
+
|
|
246
|
+
def _compress_css(self, deps: list[Style]) -> list[Style]:
|
|
247
|
+
"""Compress CSS dependencies and return new Style objects with URLs."""
|
|
248
|
+
content = _build_compressor_content(deps, "css")
|
|
249
|
+
if not content.strip():
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
compressor = CssCompressor("css", content=content)
|
|
253
|
+
output_html = compressor.output(mode="file", forced=True)
|
|
254
|
+
|
|
255
|
+
results = []
|
|
256
|
+
for item in _extract_urls_from_output(output_html, "css"):
|
|
257
|
+
if "url" in item:
|
|
258
|
+
results.append(Style(url=item["url"], attrs=item.get("attrs", {})))
|
|
259
|
+
elif "content" in item:
|
|
260
|
+
results.append(Style(content=item["content"], attrs=item.get("attrs", {})))
|
|
261
|
+
return results
|
|
262
|
+
|
|
263
|
+
def _compress_js(self, deps: list[Script]) -> list[Script]:
|
|
264
|
+
"""Compress JS dependencies and return new Script objects with URLs."""
|
|
265
|
+
content = _build_compressor_content(deps, "js")
|
|
266
|
+
if not content.strip():
|
|
267
|
+
return []
|
|
268
|
+
|
|
269
|
+
compressor = JsCompressor("js", content=content)
|
|
270
|
+
output_html = compressor.output(mode="file", forced=True)
|
|
271
|
+
|
|
272
|
+
results = []
|
|
273
|
+
for item in _extract_urls_from_output(output_html, "js"):
|
|
274
|
+
if "url" in item:
|
|
275
|
+
results.append(Script(url=item["url"], attrs=item.get("attrs", {}), wrap=False))
|
|
276
|
+
elif "content" in item:
|
|
277
|
+
results.append(
|
|
278
|
+
Script(content=item["content"], attrs=item.get("attrs", {}), wrap=False)
|
|
279
|
+
)
|
|
280
|
+
return results
|