From fd1c0339ee233ae1d7bffe1f64a090fa9b45a984 Mon Sep 17 00:00:00 2001 From: Christian Hammond Date: Mon, 11 Mar 2024 16:04:39 -0700 Subject: [PATCH] Update sourcemap paths when concatenating source files. When building a package from source files, the built source files get concatenated together before being post-processed by Django. Prior to Django 4.0, the post-processing step would normalize `url(...)` entries in CSS by looking it up in storage and replacing the path with the hashed version. Starting in Django 4.0, post-processing would do the same for sourcemaps. This can break when concatenating either CSS or JavaScript files, since Pipeline may produce a built package file that's in a different directory from one or more built source files. Django would fail to find the file and raise an error. We now include sourcemap normalization as part of the concatenation process. This is using a similar approach to `url(...)` normalization, but now consolidated into the `Compressor.concatenate()` function. This has been updated to take arguments controlling the concatenation process, such as a regex for capturing paths to normalize. The regex for capturing sourcemap lines is built to be spec-compliant, and is currently more broad than what Django looks for during post-processing. This will help avoid potential issues as Django makes changes to their process. The old functions (`concatenate_and_rewrite()`) and old default behavior has been left intact, but with runtime deprecation warnings, so that any code specializing Pipeline will continue to work. This helps ensure this change is API-compatible and non-breaking. See issue #808 for more details on the problem and the solution. --- pipeline/compressors/__init__.py | 218 +++++++++++++++++++---- pipeline/packager.py | 1 + tests/assets/css/sourcemap.css | 24 +++ tests/assets/js/sourcemap.js | 18 ++ tests/tests/test_compressor.py | 288 +++++++++++++++++++++++++++++-- 5 files changed, 508 insertions(+), 41 deletions(-) create mode 100644 tests/assets/css/sourcemap.css create mode 100644 tests/assets/js/sourcemap.js diff --git a/pipeline/compressors/__init__.py b/pipeline/compressors/__init__.py index fb1cd738..7b036eb3 100644 --- a/pipeline/compressors/__init__.py +++ b/pipeline/compressors/__init__.py @@ -1,9 +1,13 @@ +from __future__ import annotations + import base64 import os import posixpath import re import subprocess +import warnings from itertools import takewhile +from typing import Iterator, Optional, Sequence from django.contrib.staticfiles.storage import staticfiles_storage from django.utils.encoding import force_str, smart_bytes @@ -12,8 +16,58 @@ from pipeline.exceptions import CompressorError from pipeline.utils import relpath, set_std_streams_blocking, to_class -URL_DETECTOR = r"""url\((['"]?)\s*(.*?)\1\)""" -URL_REPLACER = r"""url\(__EMBED__(.+?)(\?\d+)?\)""" + +# Regex matching url(...), url('...'), and url("...") patterns. +# +# Replacements will preserve the quotes and any whitespace contained within +# the pattern, transforming only the filename. +# +# Verbose and documented, to ease future maintenance. +_CSS_URL_REWRITE_PATH_RE_STR = r""" + (?P + url\( # The opening `url(`. + (?P['"]?) # Optional quote (' or "). + \s* + ) + (?P.*?) # The path to capture. + (?P + (?P=url_quote) # The quote found earlier, if any. + \s* + \) # The end `)`, completing `url(...)`. + ) +""" + + +# Regex matching `//@ sourceMappingURL=...` and variants. +# +# This will capture sourceMappingURL and sourceURL keywords, both +# `//@` and `//#` variants, and both `//` and `/* ... */` comment types. +# +# Verbose and documented, to ease future maintenance. +_SOURCEMAP_REWRITE_PATH_RE_STR = r""" + (?P + /(?:/|(?P\*)) # Opening comment (`//#`, `//@`, + [#@]\s+ # `/*@`, `/*#`). + source(?:Mapping)?URL= # The sourcemap indicator. + \s* + ) + (?P.*?) # The path to capture. + (?P + \s* + (?(sourcemap_mlcomment)\*/\s*) # End comment (`*/`) + ) + $ # The line should now end. +""" + + +# Implementation of the above regexes, for CSS and JavaScript. +CSS_REWRITE_PATH_RE = re.compile( + f"{_CSS_URL_REWRITE_PATH_RE_STR}|{_SOURCEMAP_REWRITE_PATH_RE_STR}", re.X | re.M +) +JS_REWRITE_PATH_RE = re.compile(_SOURCEMAP_REWRITE_PATH_RE_STR, re.X | re.M) + + +URL_REPLACER = re.compile(r"""url\(__EMBED__(.+?)(\?\d+)?\)""") NON_REWRITABLE_URL = re.compile(r"^(#|http:|https:|data:|//)") DEFAULT_TEMPLATE_FUNC = "template" @@ -51,9 +105,27 @@ def js_compressor(self): def css_compressor(self): return to_class(settings.CSS_COMPRESSOR) - def compress_js(self, paths, templates=None, **kwargs): + def compress_js( + self, + paths: Sequence[str], + templates: Optional[Sequence[str]] = None, + *, + output_filename: Optional[str] = None, + **kwargs, + ) -> str: """Concatenate and compress JS files""" - js = self.concatenate(paths) + # Note how a semicolon is added between the two files to make sure that + # their behavior is not changed. '(expression1)\n(expression2)' calls + # `expression1` with `expression2` as an argument! Superfluous + # semicolons are valid in JavaScript and will be removed by the + # minifier. + js = self.concatenate( + paths, + file_sep=";", + output_filename=output_filename, + rewrite_path_re=JS_REWRITE_PATH_RE, + ) + if templates: js = js + self.compile_templates(templates) @@ -68,7 +140,13 @@ def compress_js(self, paths, templates=None, **kwargs): def compress_css(self, paths, output_filename, variant=None, **kwargs): """Concatenate and compress CSS files""" - css = self.concatenate_and_rewrite(paths, output_filename, variant) + css = self.concatenate( + paths, + file_sep="", + rewrite_path_re=CSS_REWRITE_PATH_RE, + output_filename=output_filename, + variant=variant, + ) compressor = self.css_compressor if compressor: css = getattr(compressor(verbose=self.verbose), "compress_css")(css) @@ -131,38 +209,116 @@ def template_name(self, path, base): def concatenate_and_rewrite(self, paths, output_filename, variant=None): """Concatenate together files and rewrite urls""" - stylesheets = [] - for path in paths: + warnings.warn( + "Compressor.concatenate_and_rewrite() is deprecated. Please " + "call concatenate() instead.", + DeprecationWarning, + stacklevel=2, + ) + + return self.concatenate( + paths=paths, + file_sep="", + rewrite_path_re=CSS_REWRITE_PATH_RE, + output_filename=output_filename, + variant=variant, + ) - def reconstruct(match): - quote = match.group(1) or "" - asset_path = match.group(2) - if NON_REWRITABLE_URL.match(asset_path): - return f"url({quote}{asset_path}{quote})" - asset_url = self.construct_asset_path( - asset_path, path, output_filename, variant + def concatenate( + self, + paths: Sequence[str], + *, + file_sep: Optional[str] = None, + output_filename: Optional[str] = None, + rewrite_path_re: Optional[re.Pattern] = None, + variant: Optional[str] = None, + ) -> str: + """Concatenate together a list of files. + + The caller can specify a delimiter between files and any regexes + used to normalize relative paths. Path normalization is important for + ensuring that local resources or sourcemaps can be updated in time + for Django's static media post-processing phase. + """ + + def _reconstruct( + m: re.Match, + source_path: str, + ) -> str: + groups = m.groupdict() + asset_path: Optional[str] = None + prefix = "" + suffix = "" + + for prefix in ("sourcemap", "url"): + asset_path = groups.get(f"{prefix}_path") + + if asset_path is not None: + asset_path = asset_path.strip() + prefix, suffix = m.group(f"{prefix}_prefix", f"{prefix}_suffix") + break + + if asset_path is None: + # This is empty. Return the whole match as-is. + return m.group() + + if asset_path and not NON_REWRITABLE_URL.match(asset_path): + asset_path = self.construct_asset_path( + asset_path=asset_path, + source_path=source_path, + output_filename=output_filename, + variant=variant, + ) + + return f"{prefix}{asset_path}{suffix}" + + def _iter_files() -> Iterator[str]: + if not output_filename or not rewrite_path_re: + # This is legacy call, which does not support sourcemap-aware + # asset rewriting. Pipeline itself won't invoke this outside + # of tests, but it maybe important for third-parties who + # are specializing these classes. + warnings.warn( + "Compressor.concatenate() was called without passing " + "rewrite_path_re_= or output_filename=. If you are " + "specializing Compressor, please update your call " + "to remain compatible with future changes.", + DeprecationWarning, + stacklevel=3, ) - return f"url({asset_url})" - content = self.read_text(path) - # content needs to be unicode to avoid explosions with non-ascii chars - content = re.sub(URL_DETECTOR, reconstruct, content) - stylesheets.append(content) - return "\n".join(stylesheets) + return (self.read_text(path) for path in paths) - def concatenate(self, paths): - """Concatenate together a list of files""" - # Note how a semicolon is added between the two files to make sure that - # their behavior is not changed. '(expression1)\n(expression2)' calls - # `expression1` with `expression2` as an argument! Superfluos semicolons - # are valid in JavaScript and will be removed by the minifier. - return "\n;".join([self.read_text(path) for path in paths]) + # Now that we can attempt the modern support for concatenating + # files, handling rewriting of relative assets in the process. + return ( + rewrite_path_re.sub( + lambda m: _reconstruct(m, path), self.read_text(path) + ) + for path in paths + ) + + if file_sep is None: + warnings.warn( + "Compressor.concatenate() was called without passing " + "file_sep=. If you are specializing Compressor, please " + "update your call to remain compatible with future changes. " + "Defaulting to JavaScript behavior for " + "backwards-compatibility.", + DeprecationWarning, + stacklevel=2, + ) + file_sep = ";" + + return f"\n{file_sep}".join(_iter_files()) - def construct_asset_path(self, asset_path, css_path, output_filename, variant=None): - """Return a rewritten asset URL for a stylesheet""" + def construct_asset_path( + self, asset_path, source_path, output_filename, variant=None + ): + """Return a rewritten asset URL for a stylesheet or JavaScript file.""" public_path = self.absolute_path( asset_path, - os.path.dirname(css_path).replace("\\", "/"), + os.path.dirname(source_path).replace("\\", "/"), ) if self.embeddable(public_path, variant): return "__EMBED__%s" % public_path @@ -196,7 +352,7 @@ def datauri(match): data = self.encoded_content(path) return f'url("data:{mime_type};charset=utf-8;base64,{data}")' - return re.sub(URL_REPLACER, datauri, css) + return URL_REPLACER.sub(datauri, css) def encoded_content(self, path): """Return the base64 encoded contents""" diff --git a/pipeline/packager.py b/pipeline/packager.py index f6c4a3ef..20f34197 100644 --- a/pipeline/packager.py +++ b/pipeline/packager.py @@ -152,6 +152,7 @@ def pack_javascripts(self, package, **kwargs): package, self.compressor.compress_js, js_compressed, + output_filename=package.output_filename, templates=package.templates, **kwargs, ) diff --git a/tests/assets/css/sourcemap.css b/tests/assets/css/sourcemap.css new file mode 100644 index 00000000..30e87e8b --- /dev/null +++ b/tests/assets/css/sourcemap.css @@ -0,0 +1,24 @@ +div { + display: inline; +} + +span { + display: block; +} + + +//# sourceMappingURL=sourcemap1.css.map + +//@ sourceMappingURL=sourcemap2.css.map + +/*# sourceMappingURL=sourcemap3.css.map */ + +/*@ sourceMappingURL=sourcemap4.css.map */ + +//# sourceURL=sourcemap5.css.map + +//@ sourceURL=sourcemap6.css.map + +/*# sourceURL=sourcemap7.css.map */ + +/*@ sourceURL=sourcemap8.css.map */ diff --git a/tests/assets/js/sourcemap.js b/tests/assets/js/sourcemap.js new file mode 100644 index 00000000..4d609994 --- /dev/null +++ b/tests/assets/js/sourcemap.js @@ -0,0 +1,18 @@ +const abc = 123; + + +//# sourceMappingURL=sourcemap1.js.map + +//@ sourceMappingURL=sourcemap2.js.map + +/*# sourceMappingURL=sourcemap3.js.map */ + +/*@ sourceMappingURL=sourcemap4.js.map */ + +//# sourceURL=sourcemap5.js.map + +//@ sourceURL=sourcemap6.js.map + +/*# sourceURL=sourcemap7.js.map */ + +/*@ sourceURL=sourcemap8.js.map */ diff --git a/tests/tests/test_compressor.py b/tests/tests/test_compressor.py index fc5ce538..6f5d40b8 100644 --- a/tests/tests/test_compressor.py +++ b/tests/tests/test_compressor.py @@ -14,7 +14,13 @@ from django.test.client import RequestFactory from pipeline.collector import default_collector -from pipeline.compressors import TEMPLATE_FUNC, Compressor, SubProcessCompressor +from pipeline.compressors import ( + CSS_REWRITE_PATH_RE, + Compressor, + JS_REWRITE_PATH_RE, + SubProcessCompressor, + TEMPLATE_FUNC, +) from pipeline.compressors.yuglify import YuglifyCompressor from tests.utils import _, pipeline_settings @@ -160,22 +166,25 @@ def test_construct_asset_path(self): ) self.assertEqual(asset_path, "/images/sprite.png") - def test_url_rewrite(self): - output = self.compressor.concatenate_and_rewrite( + def test_concatenate_with_url_rewrite(self) -> None: + output = self.compressor.concatenate( [ _("pipeline/css/urls.css"), ], - "css/screen.css", + file_sep="", + output_filename="css/screen.css", + rewrite_path_re=CSS_REWRITE_PATH_RE, ) + self.assertEqual( """.embedded-url-svg { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 8h24M4 16h24M4 24h24'/%3E% 3C/svg%3E"); } @font-face { font-family: 'Pipeline'; - src: url(../pipeline/fonts/pipeline.eot); - src: url(../pipeline/fonts/pipeline.eot?#iefix) format('embedded-opentype'); - src: local('☺'), url(../pipeline/fonts/pipeline.woff) format('woff'), url(../pipeline/fonts/pipeline.ttf) format('truetype'), url(../pipeline/fonts/pipeline.svg#IyfZbseF) format('svg'); + src: url('../pipeline/fonts/pipeline.eot'); + src: url('../pipeline/fonts/pipeline.eot?#iefix') format('embedded-opentype'); + src: local('☺'), url('../pipeline/fonts/pipeline.woff') format('woff'), url('../pipeline/fonts/pipeline.ttf') format('truetype'), url('../pipeline/fonts/pipeline.svg#IyfZbseF') format('svg'); font-weight: normal; font-style: normal; } @@ -202,13 +211,272 @@ def test_url_rewrite(self): output, ) - def test_url_rewrite_data_uri(self): - output = self.compressor.concatenate_and_rewrite( + def test_concatenate_with_url_rewrite_data_uri(self): + output = self.compressor.concatenate( [ _("pipeline/css/nested/nested.css"), ], - "pipeline/screen.css", + file_sep="", + output_filename="pipeline/screen.css", + rewrite_path_re=CSS_REWRITE_PATH_RE, + ) + + self.assertEqual( + """.data-url { + background-image: url(data:image/svg+xml;charset=US-ASCII,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22iso-8859-1%22%3F%3E%3C!DOCTYPE%20svg%20PUBLIC%20%22-%2F%2FW3C%2F%2FDTD%20SVG%201.1%2F%2FEN%22%20%22http%3A%2F%2Fwww.w3.org%2FGraphics%2FSVG%2F1.1%2FDTD%2Fsvg11.dtd%22%3E%3Csvg%20version%3D%221.1%22%20id%3D%22Layer_1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20x%3D%220px%22%20y%3D%220px%22%20%20width%3D%2212px%22%20height%3D%2214px%22%20viewBox%3D%220%200%2012%2014%22%20style%3D%22enable-background%3Anew%200%200%2012%2014%3B%22%20xml%3Aspace%3D%22preserve%22%3E%3Cpath%20d%3D%22M11%2C6V5c0-2.762-2.239-5-5-5S1%2C2.238%2C1%2C5v1H0v8h12V6H11z%20M6.5%2C9.847V12h-1V9.847C5.207%2C9.673%2C5%2C9.366%2C5%2C9%20c0-0.553%2C0.448-1%2C1-1s1%2C0.447%2C1%2C1C7%2C9.366%2C6.793%2C9.673%2C6.5%2C9.847z%20M9%2C6H3V5c0-1.657%2C1.343-3%2C3-3s3%2C1.343%2C3%2C3V6z%22%2F%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3C%2Fsvg%3E); +} +.data-url-quoted { + background-image: url('data:image/svg+xml;charset=US-ASCII,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22iso-8859-1%22%3F%3E%3C!DOCTYPE%20svg%20PUBLIC%20%22-%2F%2FW3C%2F%2FDTD%20SVG%201.1%2F%2FEN%22%20%22http%3A%2F%2Fwww.w3.org%2FGraphics%2FSVG%2F1.1%2FDTD%2Fsvg11.dtd%22%3E%3Csvg%20version%3D%221.1%22%20id%3D%22Layer_1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20x%3D%220px%22%20y%3D%220px%22%20%20width%3D%2212px%22%20height%3D%2214px%22%20viewBox%3D%220%200%2012%2014%22%20style%3D%22enable-background%3Anew%200%200%2012%2014%3B%22%20xml%3Aspace%3D%22preserve%22%3E%3Cpath%20d%3D%22M11%2C6V5c0-2.762-2.239-5-5-5S1%2C2.238%2C1%2C5v1H0v8h12V6H11z%20M6.5%2C9.847V12h-1V9.847C5.207%2C9.673%2C5%2C9.366%2C5%2C9%20c0-0.553%2C0.448-1%2C1-1s1%2C0.447%2C1%2C1C7%2C9.366%2C6.793%2C9.673%2C6.5%2C9.847z%20M9%2C6H3V5c0-1.657%2C1.343-3%2C3-3s3%2C1.343%2C3%2C3V6z%22%2F%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3C%2Fsvg%3E'); +} +""", # noqa + output, + ) + + def test_concatenate_css_with_sourcemap(self) -> None: + output = self.compressor.concatenate( + [ + _("pipeline/css/sourcemap.css"), + ], + file_sep="", + output_filename="css/sourcemap-bundle.css", + rewrite_path_re=CSS_REWRITE_PATH_RE, + ) + + self.assertEqual( + output, + "div {\n" + " display: inline;\n" + "}\n" + "\n" + "span {\n" + " display: block;\n" + "}\n" + "\n" + "\n" + "//# sourceMappingURL=../pipeline/css/sourcemap1.css.map\n" + "\n" + "//@ sourceMappingURL=../pipeline/css/sourcemap2.css.map \n" + "\n" + "/*# sourceMappingURL=../pipeline/css/sourcemap3.css.map */\n" + "\n" + "/*@ sourceMappingURL=../pipeline/css/sourcemap4.css.map */\n" + "\n" + "//# sourceURL=../pipeline/css/sourcemap5.css.map\n" + "\n" + "//@ sourceURL=../pipeline/css/sourcemap6.css.map \n" + "\n" + "/*# sourceURL=../pipeline/css/sourcemap7.css.map */\n" + "\n" + "/*@ sourceURL=../pipeline/css/sourcemap8.css.map */\n", + ) + + def test_concatenate_js_with_sourcemap(self) -> None: + output = self.compressor.concatenate( + [ + _("pipeline/js/sourcemap.js"), + ], + file_sep=";", + output_filename="js/sourcemap-bundle.js", + rewrite_path_re=JS_REWRITE_PATH_RE, + ) + + self.assertEqual( + output, + "const abc = 123;\n" + "\n" + "\n" + "//# sourceMappingURL=../pipeline/js/sourcemap1.js.map\n" + "\n" + "//@ sourceMappingURL=../pipeline/js/sourcemap2.js.map \n" + "\n" + "/*# sourceMappingURL=../pipeline/js/sourcemap3.js.map */\n" + "\n" + "/*@ sourceMappingURL=../pipeline/js/sourcemap4.js.map */\n" + "\n" + "//# sourceURL=../pipeline/js/sourcemap5.js.map\n" + "\n" + "//@ sourceURL=../pipeline/js/sourcemap6.js.map \n" + "\n" + "/*# sourceURL=../pipeline/js/sourcemap7.js.map */\n" + "\n" + "/*@ sourceURL=../pipeline/js/sourcemap8.js.map */\n", + ) + + def test_concatenate_without_rewrite_path_re(self) -> None: + message = ( + "Compressor.concatenate() was called without passing " + "rewrite_path_re_= or output_filename=. If you are " + "specializing Compressor, please update your call " + "to remain compatible with future changes." + ) + + with self.assertWarnsMessage(DeprecationWarning, message): + output = self.compressor.concatenate( + [ + _("pipeline/js/sourcemap.js"), + ], + file_sep=";", + output_filename="js/sourcemap-bundle.js", + ) + + self.assertEqual( + output, + "const abc = 123;\n" + "\n" + "\n" + "//# sourceMappingURL=sourcemap1.js.map\n" + "\n" + "//@ sourceMappingURL=sourcemap2.js.map \n" + "\n" + "/*# sourceMappingURL=sourcemap3.js.map */\n" + "\n" + "/*@ sourceMappingURL=sourcemap4.js.map */\n" + "\n" + "//# sourceURL=sourcemap5.js.map\n" + "\n" + "//@ sourceURL=sourcemap6.js.map \n" + "\n" + "/*# sourceURL=sourcemap7.js.map */\n" + "\n" + "/*@ sourceURL=sourcemap8.js.map */\n", + ) + + def test_concatenate_without_output_filename(self) -> None: + message = ( + "Compressor.concatenate() was called without passing " + "rewrite_path_re_= or output_filename=. If you are " + "specializing Compressor, please update your call " + "to remain compatible with future changes." + ) + + with self.assertWarnsMessage(DeprecationWarning, message): + output = self.compressor.concatenate( + [ + _("pipeline/js/sourcemap.js"), + ], + file_sep=";", + rewrite_path_re=JS_REWRITE_PATH_RE, + ) + + self.assertEqual( + output, + "const abc = 123;\n" + "\n" + "\n" + "//# sourceMappingURL=sourcemap1.js.map\n" + "\n" + "//@ sourceMappingURL=sourcemap2.js.map \n" + "\n" + "/*# sourceMappingURL=sourcemap3.js.map */\n" + "\n" + "/*@ sourceMappingURL=sourcemap4.js.map */\n" + "\n" + "//# sourceURL=sourcemap5.js.map\n" + "\n" + "//@ sourceURL=sourcemap6.js.map \n" + "\n" + "/*# sourceURL=sourcemap7.js.map */\n" + "\n" + "/*@ sourceURL=sourcemap8.js.map */\n", + ) + + def test_concatenate_without_file_sep(self) -> None: + message = ( + "Compressor.concatenate() was called without passing " + "file_sep=. If you are specializing Compressor, please " + "update your call to remain compatible with future changes. " + "Defaulting to JavaScript behavior for " + "backwards-compatibility." + ) + + with self.assertWarnsMessage(DeprecationWarning, message): + output = self.compressor.concatenate( + [ + _("pipeline/js/first.js"), + _("pipeline/js/second.js"), + ], + output_filename="js/sourcemap-bundle.js", + rewrite_path_re=JS_REWRITE_PATH_RE, + ) + + self.assertEqual( + output, + "(function() {\n" + " window.concat = function() {\n" + " console.log(arguments);\n" + " }\n" + "}()) // No semicolon\n" + "\n" + ";(function() {\n" + " window.cat = function() {\n" + ' console.log("hello world");\n' + " }\n" + "}());\n", + ) + + def test_legacy_concatenate_and_rewrite(self) -> None: + message = ( + "Compressor.concatenate_and_rewrite() is deprecated. Please " + "call concatenate() instead." + ) + + with self.assertWarnsMessage(DeprecationWarning, message): + output = self.compressor.concatenate_and_rewrite( + [ + _("pipeline/css/urls.css"), + ], + "css/screen.css", + ) + + self.assertEqual( + """.embedded-url-svg { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 8h24M4 16h24M4 24h24'/%3E% 3C/svg%3E"); +} +@font-face { + font-family: 'Pipeline'; + src: url('../pipeline/fonts/pipeline.eot'); + src: url('../pipeline/fonts/pipeline.eot?#iefix') format('embedded-opentype'); + src: local('☺'), url('../pipeline/fonts/pipeline.woff') format('woff'), url('../pipeline/fonts/pipeline.ttf') format('truetype'), url('../pipeline/fonts/pipeline.svg#IyfZbseF') format('svg'); + font-weight: normal; + font-style: normal; +} +.relative-url { + background-image: url(../pipeline/images/sprite-buttons.png); +} +.relative-url-querystring { + background-image: url(../pipeline/images/sprite-buttons.png?v=1.0#foo=bar); +} +.absolute-url { + background-image: url(/images/sprite-buttons.png); +} +.absolute-full-url { + background-image: url(http://localhost/images/sprite-buttons.png); +} +.no-protocol-url { + background-image: url(//images/sprite-buttons.png); +} +.anchor-tag-url { + background-image: url(#image-gradient); +} +@font-face{src:url(../pipeline/fonts/pipeline.eot);src:url(../pipeline/fonts/pipeline.eot?#iefix) format('embedded-opentype'),url(../pipeline/fonts/pipeline.woff) format('woff'),url(../pipeline/fonts/pipeline.ttf) format('truetype');} +""", # noqa + output, ) + + def test_legacy_concatenate_and_rewrite_with_data_uri(self) -> None: + message = ( + "Compressor.concatenate_and_rewrite() is deprecated. Please " + "call concatenate() instead." + ) + + with self.assertWarnsMessage(DeprecationWarning, message): + output = self.compressor.concatenate_and_rewrite( + [ + _("pipeline/css/nested/nested.css"), + ], + "pipeline/screen.css", + ) + self.assertEqual( """.data-url { background-image: url(data:image/svg+xml;charset=US-ASCII,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22iso-8859-1%22%3F%3E%3C!DOCTYPE%20svg%20PUBLIC%20%22-%2F%2FW3C%2F%2FDTD%20SVG%201.1%2F%2FEN%22%20%22http%3A%2F%2Fwww.w3.org%2FGraphics%2FSVG%2F1.1%2FDTD%2Fsvg11.dtd%22%3E%3Csvg%20version%3D%221.1%22%20id%3D%22Layer_1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20x%3D%220px%22%20y%3D%220px%22%20%20width%3D%2212px%22%20height%3D%2214px%22%20viewBox%3D%220%200%2012%2014%22%20style%3D%22enable-background%3Anew%200%200%2012%2014%3B%22%20xml%3Aspace%3D%22preserve%22%3E%3Cpath%20d%3D%22M11%2C6V5c0-2.762-2.239-5-5-5S1%2C2.238%2C1%2C5v1H0v8h12V6H11z%20M6.5%2C9.847V12h-1V9.847C5.207%2C9.673%2C5%2C9.366%2C5%2C9%20c0-0.553%2C0.448-1%2C1-1s1%2C0.447%2C1%2C1C7%2C9.366%2C6.793%2C9.673%2C6.5%2C9.847z%20M9%2C6H3V5c0-1.657%2C1.343-3%2C3-3s3%2C1.343%2C3%2C3V6z%22%2F%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3C%2Fsvg%3E);