Skip to content

Commit 193cc20

Browse files
authored
Merge pull request #809 from chipx86/3.x/fix-sourcemap-paths
Update sourcemap paths when concatenating source files.
2 parents 2018c11 + 58f9f99 commit 193cc20

File tree

5 files changed

+507
-41
lines changed

5 files changed

+507
-41
lines changed

pipeline/compressors/__init__.py

Lines changed: 186 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
from __future__ import annotations
2+
13
import base64
24
import os
35
import posixpath
46
import re
57
import subprocess
8+
import warnings
69
from itertools import takewhile
10+
from typing import Iterator, Optional, Sequence
711

812
from django.contrib.staticfiles.storage import staticfiles_storage
913
from django.utils.encoding import force_str, smart_bytes
@@ -12,8 +16,57 @@
1216
from pipeline.exceptions import CompressorError
1317
from pipeline.utils import relpath, set_std_streams_blocking, to_class
1418

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+)?\)""")
1770
NON_REWRITABLE_URL = re.compile(r"^(#|http:|https:|data:|//)")
1871

1972
DEFAULT_TEMPLATE_FUNC = "template"
@@ -51,9 +104,27 @@ def js_compressor(self):
51104
def css_compressor(self):
52105
return to_class(settings.CSS_COMPRESSOR)
53106

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:
55115
"""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+
57128
if templates:
58129
js = js + self.compile_templates(templates)
59130

@@ -68,7 +139,13 @@ def compress_js(self, paths, templates=None, **kwargs):
68139

69140
def compress_css(self, paths, output_filename, variant=None, **kwargs):
70141
"""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+
)
72149
compressor = self.css_compressor
73150
if compressor:
74151
css = getattr(compressor(verbose=self.verbose), "compress_css")(css)
@@ -131,38 +208,116 @@ def template_name(self, path, base):
131208

132209
def concatenate_and_rewrite(self, paths, output_filename, variant=None):
133210
"""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+
)
136217

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,
144270
)
145-
return f"url({asset_url})"
146271

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+
)
152288

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())
160313

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."""
163318
public_path = self.absolute_path(
164319
asset_path,
165-
os.path.dirname(css_path).replace("\\", "/"),
320+
os.path.dirname(source_path).replace("\\", "/"),
166321
)
167322
if self.embeddable(public_path, variant):
168323
return "__EMBED__%s" % public_path
@@ -196,7 +351,7 @@ def datauri(match):
196351
data = self.encoded_content(path)
197352
return f'url("data:{mime_type};charset=utf-8;base64,{data}")'
198353

199-
return re.sub(URL_REPLACER, datauri, css)
354+
return URL_REPLACER.sub(datauri, css)
200355

201356
def encoded_content(self, path):
202357
"""Return the base64 encoded contents"""

pipeline/packager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ def pack_javascripts(self, package, **kwargs):
152152
package,
153153
self.compressor.compress_js,
154154
js_compressed,
155+
output_filename=package.output_filename,
155156
templates=package.templates,
156157
**kwargs,
157158
)

tests/assets/css/sourcemap.css

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/assets/js/sourcemap.js

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)