|
| 1 | +import os |
| 2 | +import posixpath |
| 3 | +import re |
| 4 | +from typing import List, Match, Optional |
| 5 | + |
| 6 | +from .. import exceptions, url_converter, utils |
| 7 | +from ..types import StrCollection |
| 8 | +from . import base |
| 9 | + |
| 10 | +__all__ = ( |
| 11 | + "SCSS", |
| 12 | + "SASS", |
| 13 | +) |
| 14 | + |
| 15 | + |
| 16 | +class SCSS(base.BaseCompiler): |
| 17 | + |
| 18 | + name = "scss" |
| 19 | + supports_dependencies = True |
| 20 | + input_extension = "scss" |
| 21 | + output_extension = "css" |
| 22 | + import_extensions = ("scss", "sass") |
| 23 | + |
| 24 | + IMPORT_RE = re.compile(r"@import\s+(.+?)\s*;", re.DOTALL) |
| 25 | + |
| 26 | + def __init__( |
| 27 | + self, |
| 28 | + executable: str = "sass", |
| 29 | + sourcemap_enabled: bool = False, |
| 30 | + compass_enabled: bool = False, |
| 31 | + load_paths: Optional[StrCollection] = None, |
| 32 | + precision: Optional[int] = None, |
| 33 | + output_style: Optional[str] = None, |
| 34 | + ): |
| 35 | + self.executable = executable |
| 36 | + self.is_sourcemap_enabled = sourcemap_enabled |
| 37 | + self.is_compass_enabled = compass_enabled |
| 38 | + self.precision = precision |
| 39 | + self.output_style = output_style |
| 40 | + self.load_paths: StrCollection = load_paths or [] |
| 41 | + super().__init__() |
| 42 | + |
| 43 | + def get_extra_args(self) -> List[str]: |
| 44 | + args = [] |
| 45 | + |
| 46 | + for path in self.load_paths: |
| 47 | + args += ["-I", path] |
| 48 | + |
| 49 | + if self.is_compass_enabled: |
| 50 | + args.append("--compass") |
| 51 | + |
| 52 | + if self.precision: |
| 53 | + args += ["--precision", str(self.precision)] |
| 54 | + |
| 55 | + if self.output_style: |
| 56 | + args += ["-t", self.output_style] |
| 57 | + |
| 58 | + return args |
| 59 | + |
| 60 | + def should_compile(self, source_path: str, from_management: bool = False) -> bool: |
| 61 | + # Do not compile the files that start with "_" if run from management |
| 62 | + if from_management and os.path.basename(source_path).startswith("_"): |
| 63 | + return False |
| 64 | + return super().should_compile(source_path, from_management) |
| 65 | + |
| 66 | + def compile_file(self, source_path: str) -> str: |
| 67 | + full_source_path = self.get_full_source_path(source_path) |
| 68 | + full_output_path = self.get_full_output_path(source_path) |
| 69 | + args = [ |
| 70 | + self.executable, |
| 71 | + "--sourcemap={}".format("auto" if self.is_sourcemap_enabled else "none"), |
| 72 | + ] + self.get_extra_args() |
| 73 | + |
| 74 | + args.extend( |
| 75 | + [ |
| 76 | + self.get_full_source_path(source_path), |
| 77 | + full_output_path, |
| 78 | + ] |
| 79 | + ) |
| 80 | + |
| 81 | + full_output_dirname = os.path.dirname(full_output_path) |
| 82 | + if not os.path.exists(full_output_dirname): |
| 83 | + os.makedirs(full_output_dirname) |
| 84 | + |
| 85 | + # `cwd` is a directory containing `source_path`. |
| 86 | + # Ex: source_path = '1/2/3', full_source_path = '/abc/1/2/3' -> cwd = '/abc' |
| 87 | + cwd = os.path.normpath(os.path.join(full_source_path, *([".."] * len(source_path.split("/"))))) |
| 88 | + return_code, out, errors = utils.run_command(args, cwd=cwd) |
| 89 | + |
| 90 | + if return_code: |
| 91 | + if os.path.exists(full_output_path): |
| 92 | + os.remove(full_output_path) |
| 93 | + raise exceptions.StaticCompilationError(errors) |
| 94 | + |
| 95 | + url_converter.convert_urls(full_output_path, source_path) |
| 96 | + |
| 97 | + if self.is_sourcemap_enabled: |
| 98 | + utils.fix_sourcemap(full_output_path + ".map", source_path, full_output_path) |
| 99 | + |
| 100 | + return self.get_output_path(source_path) |
| 101 | + |
| 102 | + def compile_source(self, source: str) -> str: |
| 103 | + args = [ |
| 104 | + self.executable, |
| 105 | + "-s", |
| 106 | + ] + self.get_extra_args() |
| 107 | + |
| 108 | + if self.executable.endswith("sass"): |
| 109 | + args.append("--scss") |
| 110 | + |
| 111 | + return_code, out, errors = utils.run_command(args, input=source) |
| 112 | + if return_code: |
| 113 | + raise exceptions.StaticCompilationError(errors) |
| 114 | + |
| 115 | + return out |
| 116 | + |
| 117 | + # noinspection PyMethodMayBeStatic |
| 118 | + def parse_import_string(self, import_string: str) -> List[str]: |
| 119 | + """Extract import items from import string. |
| 120 | + :param import_string: import string |
| 121 | + """ |
| 122 | + items = [] |
| 123 | + item = "" |
| 124 | + in_quotes = False |
| 125 | + quote = "" |
| 126 | + in_parentheses = False |
| 127 | + item_allowed = True |
| 128 | + |
| 129 | + for char in import_string: |
| 130 | + |
| 131 | + if char == ")": |
| 132 | + in_parentheses = False |
| 133 | + continue |
| 134 | + |
| 135 | + if in_parentheses: |
| 136 | + continue |
| 137 | + |
| 138 | + if char == "(": |
| 139 | + item = "" |
| 140 | + in_parentheses = True |
| 141 | + continue |
| 142 | + |
| 143 | + if char == ",": |
| 144 | + if in_quotes: |
| 145 | + item += char |
| 146 | + else: |
| 147 | + if item: |
| 148 | + items.append(item) |
| 149 | + item = "" |
| 150 | + item_allowed = True |
| 151 | + continue |
| 152 | + |
| 153 | + if char in " \t\n\r\f\v": |
| 154 | + if in_quotes: |
| 155 | + item += char |
| 156 | + elif item: |
| 157 | + items.append(item) |
| 158 | + item_allowed = False |
| 159 | + item = "" |
| 160 | + continue |
| 161 | + |
| 162 | + if char in "\"'": |
| 163 | + if in_quotes: |
| 164 | + if char == quote: |
| 165 | + # Close quote |
| 166 | + in_quotes = False |
| 167 | + else: |
| 168 | + item += char |
| 169 | + else: |
| 170 | + in_quotes = True |
| 171 | + quote = char |
| 172 | + continue |
| 173 | + |
| 174 | + if not item_allowed: |
| 175 | + break |
| 176 | + |
| 177 | + item += char |
| 178 | + |
| 179 | + if item: |
| 180 | + items.append(item) |
| 181 | + |
| 182 | + return sorted(items) |
| 183 | + |
| 184 | + def strip_comments(self, source: str) -> str: |
| 185 | + """Strip comments from source, it does not remove comments inside |
| 186 | + strings or comments inside functions calls. |
| 187 | +
|
| 188 | + Contribution taken from and added function call pattern |
| 189 | + https://stackoverflow.com/questions/2319019/using-regex-to-remove-comments-from-source-files |
| 190 | +
|
| 191 | + :param source: source code |
| 192 | + """ |
| 193 | + pattern = r"(\".*?\"|\'.*?\'|\(.*?\))|(\s*/\*.*?\*/|\s*//[^\r\n]*$)" |
| 194 | + # first group captures quoted sources (double or single) |
| 195 | + # second group captures comments (//single-line or /* multi-line */) |
| 196 | + regex = re.compile(pattern, re.MULTILINE | re.DOTALL) |
| 197 | + |
| 198 | + def _replacer(match: Match[str]) -> str: |
| 199 | + # if the 2nd group (capturing comments) is not None, |
| 200 | + # it means we have captured a non-quoted (real) comment source. |
| 201 | + if match.group(2) is not None: |
| 202 | + return "" # so we will return empty to remove the comment |
| 203 | + else: # otherwise, we will return the 1st group |
| 204 | + return match.group(1) # captured quoted-source or function call |
| 205 | + |
| 206 | + return regex.sub(_replacer, source) |
| 207 | + |
| 208 | + def find_imports(self, source: str) -> List[str]: |
| 209 | + """Find the imported files in the source code. |
| 210 | +
|
| 211 | + :param source: source code |
| 212 | + """ |
| 213 | + source = self.strip_comments(source) |
| 214 | + imports = set() |
| 215 | + for import_string in self.IMPORT_RE.findall(source): |
| 216 | + for import_item in self.parse_import_string(import_string): |
| 217 | + import_item = import_item.strip() |
| 218 | + if not import_item: |
| 219 | + continue |
| 220 | + if import_item.endswith(".css"): |
| 221 | + continue |
| 222 | + if import_item.startswith("http://") or import_item.startswith("https://"): |
| 223 | + continue |
| 224 | + if self.is_compass_enabled and ( |
| 225 | + import_item in ("compass", "compass.scss") or import_item.startswith("compass/") |
| 226 | + ): |
| 227 | + # Ignore compass imports if Compass is enabled. |
| 228 | + continue |
| 229 | + imports.add(import_item) |
| 230 | + return sorted(imports) |
| 231 | + |
| 232 | + def get_full_source_path(self, source_path: str) -> str: |
| 233 | + try: |
| 234 | + return super().get_full_source_path(source_path) |
| 235 | + except ValueError: |
| 236 | + # Try to locate the source file in directories specified in `load_paths` |
| 237 | + norm_source_path = utils.normalize_path(source_path.lstrip("/")) |
| 238 | + for dirname in self.load_paths: |
| 239 | + full_path = os.path.join(dirname, norm_source_path) |
| 240 | + if os.path.exists(full_path): |
| 241 | + return full_path |
| 242 | + raise |
| 243 | + |
| 244 | + def locate_imported_file(self, source_dir: str, import_path: str) -> str: |
| 245 | + """Locate the imported file in the source directory. |
| 246 | + Return the path to the imported file relative to STATIC_ROOT |
| 247 | +
|
| 248 | + :param source_dir: source directory |
| 249 | + :param import_path: path to the imported file |
| 250 | + """ |
| 251 | + import_filename = posixpath.basename(import_path) |
| 252 | + import_dirname = posixpath.dirname(import_path) |
| 253 | + import_filename_root, import_filename_extension = posixpath.splitext(import_filename) |
| 254 | + |
| 255 | + if import_filename_extension: |
| 256 | + filenames_to_try = [import_filename] |
| 257 | + else: |
| 258 | + # No extension is specified for the imported file, try all supported extensions |
| 259 | + filenames_to_try = [import_filename_root + "." + extension for extension in self.import_extensions] |
| 260 | + |
| 261 | + if not import_filename.startswith("_"): |
| 262 | + # Try the files with "_" prefix |
| 263 | + filenames_to_try += ["_" + filename for filename in filenames_to_try] |
| 264 | + |
| 265 | + # Try to locate the file in the directory relative to `source_dir` |
| 266 | + for filename in filenames_to_try: |
| 267 | + source_path = posixpath.normpath(posixpath.join(source_dir, import_dirname, filename)) |
| 268 | + try: |
| 269 | + self.get_full_source_path(source_path) |
| 270 | + return source_path |
| 271 | + except ValueError: |
| 272 | + pass |
| 273 | + |
| 274 | + # Try to locate the file in the directories listed in `load_paths` |
| 275 | + for dirname in self.load_paths: |
| 276 | + for filename in filenames_to_try: |
| 277 | + source_path = posixpath.join(import_dirname, filename) |
| 278 | + if os.path.exists(os.path.join(dirname, utils.normalize_path(source_path))): |
| 279 | + return source_path |
| 280 | + |
| 281 | + raise exceptions.StaticCompilationError(f"Can't locate the imported file: {import_path}") |
| 282 | + |
| 283 | + def find_dependencies(self, source_path: str) -> List[str]: |
| 284 | + source = self.get_source(source_path) |
| 285 | + source_dir = posixpath.dirname(source_path) |
| 286 | + dependencies = set() |
| 287 | + for import_path in self.find_imports(source): |
| 288 | + import_path = self.locate_imported_file(source_dir, import_path) |
| 289 | + dependencies.add(import_path) |
| 290 | + dependencies.update(self.find_dependencies(import_path)) |
| 291 | + return sorted(dependencies) |
| 292 | + |
| 293 | + |
| 294 | +# noinspection PyAbstractClass |
| 295 | +class SASS(SCSS): |
| 296 | + |
| 297 | + name = "sass" |
| 298 | + input_extension = "sass" |
| 299 | + import_extensions = ("sass", "scss") |
| 300 | + |
| 301 | + IMPORT_RE = re.compile(r"@import\s+(.+?)\s*(?:\n|$)") |
| 302 | + |
| 303 | + def compile_source(self, source: str) -> str: |
| 304 | + args = [ |
| 305 | + self.executable, |
| 306 | + "-s", |
| 307 | + ] + self.get_extra_args() |
| 308 | + if self.executable.endswith("scss"): |
| 309 | + args.append("--sass") |
| 310 | + |
| 311 | + return_code, out, errors = utils.run_command(args, input=source) |
| 312 | + if return_code: |
| 313 | + raise exceptions.StaticCompilationError(errors) |
| 314 | + |
| 315 | + return out |
0 commit comments