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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ venv/
3
+ citry-src/
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ db.sqlite3
8
+ .static/
9
+ .media/
10
+ .pytest_cache/
11
+ .ruff_cache/
@@ -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