1
+ from __future__ import annotations
2
+
1
3
import base64
2
4
import os
3
5
import posixpath
4
6
import re
5
7
import subprocess
8
+ import warnings
6
9
from itertools import takewhile
10
+ from typing import Iterator , Optional , Sequence
7
11
8
12
from django .contrib .staticfiles .storage import staticfiles_storage
9
13
from django .utils .encoding import force_str , smart_bytes
12
16
from pipeline .exceptions import CompressorError
13
17
from pipeline .utils import relpath , set_std_streams_blocking , to_class
14
18
15
- URL_DETECTOR = r"""url\((['"]?)\s*(.*?)\1\)"""
16
- URL_REPLACER = r"""url\(__EMBED__(.+?)(\?\d+)?\)"""
19
+ # Regex matching url(...), url('...'), and url("...") patterns.
20
+ #
21
+ # Replacements will preserve the quotes and any whitespace contained within
22
+ # the pattern, transforming only the filename.
23
+ #
24
+ # Verbose and documented, to ease future maintenance.
25
+ _CSS_URL_REWRITE_PATH_RE_STR = r"""
26
+ (?P<url_prefix>
27
+ url\( # The opening `url(`.
28
+ (?P<url_quote>['"]?) # Optional quote (' or ").
29
+ \s*
30
+ )
31
+ (?P<url_path>.*?) # The path to capture.
32
+ (?P<url_suffix>
33
+ (?P=url_quote) # The quote found earlier, if any.
34
+ \s*
35
+ \) # The end `)`, completing `url(...)`.
36
+ )
37
+ """
38
+
39
+
40
+ # Regex matching `//@ sourceMappingURL=...` and variants.
41
+ #
42
+ # This will capture sourceMappingURL and sourceURL keywords, both
43
+ # `//@` and `//#` variants, and both `//` and `/* ... */` comment types.
44
+ #
45
+ # Verbose and documented, to ease future maintenance.
46
+ _SOURCEMAP_REWRITE_PATH_RE_STR = r"""
47
+ (?P<sourcemap_prefix>
48
+ /(?:/|(?P<sourcemap_mlcomment>\*)) # Opening comment (`//#`, `//@`,
49
+ [#@]\s+ # `/*@`, `/*#`).
50
+ source(?:Mapping)?URL= # The sourcemap indicator.
51
+ \s*
52
+ )
53
+ (?P<sourcemap_path>.*?) # The path to capture.
54
+ (?P<sourcemap_suffix>
55
+ \s*
56
+ (?(sourcemap_mlcomment)\*/\s*) # End comment (`*/`)
57
+ )
58
+ $ # The line should now end.
59
+ """
60
+
61
+
62
+ # Implementation of the above regexes, for CSS and JavaScript.
63
+ CSS_REWRITE_PATH_RE = re .compile (
64
+ f"{ _CSS_URL_REWRITE_PATH_RE_STR } |{ _SOURCEMAP_REWRITE_PATH_RE_STR } " , re .X | re .M
65
+ )
66
+ JS_REWRITE_PATH_RE = re .compile (_SOURCEMAP_REWRITE_PATH_RE_STR , re .X | re .M )
67
+
68
+
69
+ URL_REPLACER = re .compile (r"""url\(__EMBED__(.+?)(\?\d+)?\)""" )
17
70
NON_REWRITABLE_URL = re .compile (r"^(#|http:|https:|data:|//)" )
18
71
19
72
DEFAULT_TEMPLATE_FUNC = "template"
@@ -51,9 +104,27 @@ def js_compressor(self):
51
104
def css_compressor (self ):
52
105
return to_class (settings .CSS_COMPRESSOR )
53
106
54
- def compress_js (self , paths , templates = None , ** kwargs ):
107
+ def compress_js (
108
+ self ,
109
+ paths : Sequence [str ],
110
+ templates : Optional [Sequence [str ]] = None ,
111
+ * ,
112
+ output_filename : Optional [str ] = None ,
113
+ ** kwargs ,
114
+ ) -> str :
55
115
"""Concatenate and compress JS files"""
56
- js = self .concatenate (paths )
116
+ # Note how a semicolon is added between the two files to make sure that
117
+ # their behavior is not changed. '(expression1)\n(expression2)' calls
118
+ # `expression1` with `expression2` as an argument! Superfluous
119
+ # semicolons are valid in JavaScript and will be removed by the
120
+ # minifier.
121
+ js = self .concatenate (
122
+ paths ,
123
+ file_sep = ";" ,
124
+ output_filename = output_filename ,
125
+ rewrite_path_re = JS_REWRITE_PATH_RE ,
126
+ )
127
+
57
128
if templates :
58
129
js = js + self .compile_templates (templates )
59
130
@@ -68,7 +139,13 @@ def compress_js(self, paths, templates=None, **kwargs):
68
139
69
140
def compress_css (self , paths , output_filename , variant = None , ** kwargs ):
70
141
"""Concatenate and compress CSS files"""
71
- css = self .concatenate_and_rewrite (paths , output_filename , variant )
142
+ css = self .concatenate (
143
+ paths ,
144
+ file_sep = "" ,
145
+ rewrite_path_re = CSS_REWRITE_PATH_RE ,
146
+ output_filename = output_filename ,
147
+ variant = variant ,
148
+ )
72
149
compressor = self .css_compressor
73
150
if compressor :
74
151
css = getattr (compressor (verbose = self .verbose ), "compress_css" )(css )
@@ -131,38 +208,116 @@ def template_name(self, path, base):
131
208
132
209
def concatenate_and_rewrite (self , paths , output_filename , variant = None ):
133
210
"""Concatenate together files and rewrite urls"""
134
- stylesheets = []
135
- for path in paths :
211
+ warnings .warn (
212
+ "Compressor.concatenate_and_rewrite() is deprecated. Please "
213
+ "call concatenate() instead." ,
214
+ DeprecationWarning ,
215
+ stacklevel = 2 ,
216
+ )
136
217
137
- def reconstruct (match ):
138
- quote = match .group (1 ) or ""
139
- asset_path = match .group (2 )
140
- if NON_REWRITABLE_URL .match (asset_path ):
141
- return f"url({ quote } { asset_path } { quote } )"
142
- asset_url = self .construct_asset_path (
143
- asset_path , path , output_filename , variant
218
+ return self .concatenate (
219
+ paths = paths ,
220
+ file_sep = "" ,
221
+ rewrite_path_re = CSS_REWRITE_PATH_RE ,
222
+ output_filename = output_filename ,
223
+ variant = variant ,
224
+ )
225
+
226
+ def concatenate (
227
+ self ,
228
+ paths : Sequence [str ],
229
+ * ,
230
+ file_sep : Optional [str ] = None ,
231
+ output_filename : Optional [str ] = None ,
232
+ rewrite_path_re : Optional [re .Pattern ] = None ,
233
+ variant : Optional [str ] = None ,
234
+ ) -> str :
235
+ """Concatenate together a list of files.
236
+
237
+ The caller can specify a delimiter between files and any regexes
238
+ used to normalize relative paths. Path normalization is important for
239
+ ensuring that local resources or sourcemaps can be updated in time
240
+ for Django's static media post-processing phase.
241
+ """
242
+
243
+ def _reconstruct (
244
+ m : re .Match ,
245
+ source_path : str ,
246
+ ) -> str :
247
+ groups = m .groupdict ()
248
+ asset_path : Optional [str ] = None
249
+ prefix = ""
250
+ suffix = ""
251
+
252
+ for prefix in ("sourcemap" , "url" ):
253
+ asset_path = groups .get (f"{ prefix } _path" )
254
+
255
+ if asset_path is not None :
256
+ asset_path = asset_path .strip ()
257
+ prefix , suffix = m .group (f"{ prefix } _prefix" , f"{ prefix } _suffix" )
258
+ break
259
+
260
+ if asset_path is None :
261
+ # This is empty. Return the whole match as-is.
262
+ return m .group ()
263
+
264
+ if asset_path and not NON_REWRITABLE_URL .match (asset_path ):
265
+ asset_path = self .construct_asset_path (
266
+ asset_path = asset_path ,
267
+ source_path = source_path ,
268
+ output_filename = output_filename ,
269
+ variant = variant ,
144
270
)
145
- return f"url({ asset_url } )"
146
271
147
- content = self .read_text (path )
148
- # content needs to be unicode to avoid explosions with non-ascii chars
149
- content = re .sub (URL_DETECTOR , reconstruct , content )
150
- stylesheets .append (content )
151
- return "\n " .join (stylesheets )
272
+ return f"{ prefix } { asset_path } { suffix } "
273
+
274
+ def _iter_files () -> Iterator [str ]:
275
+ if not output_filename or not rewrite_path_re :
276
+ # This is legacy call, which does not support sourcemap-aware
277
+ # asset rewriting. Pipeline itself won't invoke this outside
278
+ # of tests, but it maybe important for third-parties who
279
+ # are specializing these classes.
280
+ warnings .warn (
281
+ "Compressor.concatenate() was called without passing "
282
+ "rewrite_path_re_= or output_filename=. If you are "
283
+ "specializing Compressor, please update your call "
284
+ "to remain compatible with future changes." ,
285
+ DeprecationWarning ,
286
+ stacklevel = 3 ,
287
+ )
152
288
153
- def concatenate (self , paths ):
154
- """Concatenate together a list of files"""
155
- # Note how a semicolon is added between the two files to make sure that
156
- # their behavior is not changed. '(expression1)\n(expression2)' calls
157
- # `expression1` with `expression2` as an argument! Superfluos semicolons
158
- # are valid in JavaScript and will be removed by the minifier.
159
- return "\n ;" .join ([self .read_text (path ) for path in paths ])
289
+ return (self .read_text (path ) for path in paths )
290
+
291
+ # Now that we can attempt the modern support for concatenating
292
+ # files, handling rewriting of relative assets in the process.
293
+ return (
294
+ rewrite_path_re .sub (
295
+ lambda m : _reconstruct (m , path ), self .read_text (path )
296
+ )
297
+ for path in paths
298
+ )
299
+
300
+ if file_sep is None :
301
+ warnings .warn (
302
+ "Compressor.concatenate() was called without passing "
303
+ "file_sep=. If you are specializing Compressor, please "
304
+ "update your call to remain compatible with future changes. "
305
+ "Defaulting to JavaScript behavior for "
306
+ "backwards-compatibility." ,
307
+ DeprecationWarning ,
308
+ stacklevel = 2 ,
309
+ )
310
+ file_sep = ";"
311
+
312
+ return f"\n { file_sep } " .join (_iter_files ())
160
313
161
- def construct_asset_path (self , asset_path , css_path , output_filename , variant = None ):
162
- """Return a rewritten asset URL for a stylesheet"""
314
+ def construct_asset_path (
315
+ self , asset_path , source_path , output_filename , variant = None
316
+ ):
317
+ """Return a rewritten asset URL for a stylesheet or JavaScript file."""
163
318
public_path = self .absolute_path (
164
319
asset_path ,
165
- os .path .dirname (css_path ).replace ("\\ " , "/" ),
320
+ os .path .dirname (source_path ).replace ("\\ " , "/" ),
166
321
)
167
322
if self .embeddable (public_path , variant ):
168
323
return "__EMBED__%s" % public_path
@@ -196,7 +351,7 @@ def datauri(match):
196
351
data = self .encoded_content (path )
197
352
return f'url("data:{ mime_type } ;charset=utf-8;base64,{ data } ")'
198
353
199
- return re .sub (URL_REPLACER , datauri , css )
354
+ return URL_REPLACER .sub (datauri , css )
200
355
201
356
def encoded_content (self , path ):
202
357
"""Return the base64 encoded contents"""
0 commit comments