From 613e130b7b9bc93ed0c8b8f65494192724fc577e Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 2 Nov 2024 18:54:38 +0000 Subject: [PATCH] [IMPL] - added support for sass and scss stylesheet languages - better checking of stylesheets to be compiled - added support for sass and scss stylesheet languages - the stylesheets files are now copied to ".web/styles/" at compile time - relock poetry file for libsass deps - stylesheet compiler unit tests also check the contents of the file --- pyproject.toml | 1 + reflex/compiler/compiler.py | 64 ++++++++++++++++++++++++--- reflex/constants/base.py | 3 ++ reflex/utils/build.py | 3 ++ reflex/utils/path_ops.py | 14 +++++- tests/units/compiler/test_compiler.py | 41 +++++++++++++---- 6 files changed, 111 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 20bf81d92..1f0e1a18f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ twine = ">=4.0.0,<6.0" tomlkit = ">=0.12.4,<1.0" lazy_loader = ">=0.4" reflex-chakra = ">=0.6.0" +libsass = "0.23.0" [tool.poetry.group.dev.dependencies] pytest = ">=7.1.2,<9.0" diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 9f81f319d..57c851b03 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -4,8 +4,12 @@ from __future__ import annotations from datetime import datetime from pathlib import Path +from re import IGNORECASE as RE_IGNORECASE +from re import compile as re_compile from typing import TYPE_CHECKING, Dict, Iterable, Optional, Tuple, Type, Union +from sass import compile as sass_compile + from reflex import constants from reflex.compiler import templates, utils from reflex.components.base.fragment import Fragment @@ -24,6 +28,8 @@ from reflex.utils.imports import ImportVar from reflex.utils.prerequisites import get_web_dir from reflex.vars.base import LiteralVar, Var +RE_SASS_SCSS_EXT = re_compile(r"\.s(c|a)ss", flags=RE_IGNORECASE) + def _compile_document_root(root: Component) -> str: """Compile the document root. @@ -189,18 +195,66 @@ def _compile_root_stylesheet(stylesheets: list[str]) -> str: if get_config().tailwind is not None else [] ) - for stylesheet in stylesheets: + + while len(stylesheets): + stylesheet = stylesheets.pop(0) if not utils.is_valid_url(stylesheet): # check if stylesheet provided exists. - stylesheet_full_path = ( - Path.cwd() / constants.Dirs.APP_ASSETS / stylesheet.strip("/") - ) + assets_app_path = Path.cwd() / constants.Dirs.APP_ASSETS + stylesheet_full_path = assets_app_path / stylesheet.strip("/") + if not stylesheet_full_path.exists(): raise FileNotFoundError( f"The stylesheet file {stylesheet_full_path} does not exist." ) - stylesheet = f"../{constants.Dirs.PUBLIC}/{stylesheet.strip('/')}" + elif not stylesheet_full_path.is_file(): + if stylesheet_full_path.is_dir(): + # NOTE: this can create an infinite loop, for example: + # assets/ + # | dir_a/ + # | | dir_c/ (symlink to "assets/dir_a") + # | dir_b/ + # so to avoid the infinite loop, we don't include symbolic links + stylesheets += [ + str(p.relative_to(assets_app_path)) + for p in stylesheet_full_path.iterdir() + if not p.is_symlink() + ] + continue + else: + raise FileNotFoundError( + f'The stylesheet path "{stylesheet_full_path}" is not a valid path.' + ) + elif ( + stylesheet_full_path.suffix[1:] + not in constants.Reflex.STYLESHEETS_SUPPORTED + ): + raise FileNotFoundError( + f'The stylesheet file "{stylesheet_full_path}" is not a valid file.' + ) + + if ( + stylesheet_full_path.suffix[1:] + in constants.Reflex.STYLESHEETS_SUPPORTED + ): + target = ( + Path.cwd() + / f"{constants.Dirs.WEB}/{constants.Dirs.STYLES}/{RE_SASS_SCSS_EXT.sub(".css", str(stylesheet)).strip('/')}" + ) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + data=sass_compile( + filename=str(stylesheet_full_path), output_style="compressed" + ), + encoding="utf8", + ) + else: + pass + + stylesheet = f"./{RE_SASS_SCSS_EXT.sub(".css", str(stylesheet)).strip('/')}" + sheets.append(stylesheet) if stylesheet not in sheets else None + return templates.STYLE.render(stylesheets=sheets) diff --git a/reflex/constants/base.py b/reflex/constants/base.py index 798ac7dc6..18ffc35dc 100644 --- a/reflex/constants/base.py +++ b/reflex/constants/base.py @@ -80,6 +80,9 @@ class Reflex(SimpleNamespace): RELEASES_URL = "https://api.github.com/repos/reflex-dev/templates/releases" + # The reflex stylesheet language supported + STYLESHEETS_SUPPORTED = ["css", "sass", "scss"] + class ReflexHostingCLI(SimpleNamespace): """Base constants concerning Reflex Hosting CLI.""" diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 14709d99c..119b163d3 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -220,6 +220,9 @@ def setup_frontend( path_ops.cp( src=str(root / constants.Dirs.APP_ASSETS), dest=str(root / prerequisites.get_web_dir() / constants.Dirs.PUBLIC), + ignore=tuple( + f"*.{ext}" for ext in constants.Reflex.STYLESHEETS_SUPPORTED + ), # ignore stylesheet files precompiled in the compiler ) # Set the environment variables in client (env.json). diff --git a/reflex/utils/path_ops.py b/reflex/utils/path_ops.py index a2ba2b151..08280183b 100644 --- a/reflex/utils/path_ops.py +++ b/reflex/utils/path_ops.py @@ -28,13 +28,19 @@ def rm(path: str | Path): path.unlink() -def cp(src: str | Path, dest: str | Path, overwrite: bool = True) -> bool: +def cp( + src: str | Path, + dest: str | Path, + overwrite: bool = True, + ignore: tuple[str, ...] | None = None, +) -> bool: """Copy a file or directory. Args: src: The path to the file or directory. dest: The path to the destination. overwrite: Whether to overwrite the destination. + ignore: Ignoring files and directories that match one of the glob-style patterns provided Returns: Whether the copy was successful. @@ -46,7 +52,11 @@ def cp(src: str | Path, dest: str | Path, overwrite: bool = True) -> bool: return False if src.is_dir(): rm(dest) - shutil.copytree(src, dest) + shutil.copytree( + src, + dest, + ignore=shutil.ignore_patterns(*ignore) if ignore is not None else ignore, + ) else: shutil.copyfile(src, dest) return True diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 22f5c8483..c4658d7f7 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -106,7 +106,7 @@ def test_compile_imports(import_dict: ParsedImportDict, test_dicts: List[dict]): assert sorted(import_dict["rest"]) == test_dict["rest"] # type: ignore -def test_compile_stylesheets(tmp_path, mocker): +def test_compile_stylesheets(tmp_path: Path, mocker): """Test that stylesheets compile correctly. Args: @@ -119,25 +119,50 @@ def test_compile_stylesheets(tmp_path, mocker): assets_dir = project / "assets" assets_dir.mkdir() - (assets_dir / "styles.css").touch() + assets_preprocess_dir = project / "assets" / "preprocess" + assets_preprocess_dir.mkdir() + + (assets_dir / "styles.css").write_text( + "button.rt-Button {\n\tborder-radius:unset !important;\n}" + ) + (assets_preprocess_dir / "styles_a.sass").write_text( + "button.rt-Button\n\tborder-radius:unset !important" + ) + (assets_preprocess_dir / "styles_b.scss").write_text( + "button.rt-Button {\n\tborder-radius:unset !important;\n}" + ) mocker.patch("reflex.compiler.compiler.Path.cwd", return_value=project) stylesheets = [ "https://fonts.googleapis.com/css?family=Sofia&effect=neon|outline|emboss|shadow-multiple", "https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css", "/styles.css", + "/preprocess/styles_a.sass", + "/preprocess/styles_b.scss", "https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css", ] assert compiler.compile_root_stylesheet(stylesheets) == ( str(Path(".web") / "styles" / "styles.css"), - "@import url('./tailwind.css'); \n" - "@import url('https://fonts.googleapis.com/css?family=Sofia&effect=neon|outline|emboss|shadow-multiple'); \n" - "@import url('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css'); \n" - "@import url('../public/styles.css'); \n" - "@import url('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css'); \n", + f"@import url('./tailwind.css'); \n" + f"@import url('https://fonts.googleapis.com/css?family=Sofia&effect=neon|outline|emboss|shadow-multiple'); \n" + f"@import url('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css'); \n" + f"@import url('./styles.css'); \n" + f"@import url('./preprocess/styles_a.css'); \n" + f"@import url('./preprocess/styles_b.css'); \n" + f"@import url('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css'); \n", ) + # NOTE: the css file is also inserted into the s(a|c)ss preprocessor, which compressed the result, which means we don't have the tab, return,... characters. + expected_result = "button.rt-Button{border-radius:unset !important}\n" + assert (project / ".web" / "styles" / "styles.css").read_text() == expected_result + assert ( + project / ".web" / "styles" / "preprocess" / "styles_a.css" + ).read_text() == expected_result + assert ( + project / ".web" / "styles" / "preprocess" / "styles_b.css" + ).read_text() == expected_result + def test_compile_stylesheets_exclude_tailwind(tmp_path, mocker): """Test that Tailwind is excluded if tailwind config is explicitly set to None. @@ -165,7 +190,7 @@ def test_compile_stylesheets_exclude_tailwind(tmp_path, mocker): assert compiler.compile_root_stylesheet(stylesheets) == ( str(Path(".web") / "styles" / "styles.css"), - "@import url('../public/styles.css'); \n", + "@import url('./styles.css'); \n", )