diff --git a/reflex/.templates/jinja/web/styles/styles.css.jinja2 b/reflex/.templates/jinja/web/styles/styles.css.jinja2 new file mode 100644 index 000000000..1600fe55c --- /dev/null +++ b/reflex/.templates/jinja/web/styles/styles.css.jinja2 @@ -0,0 +1,5 @@ +{%- block imports_styles %} +{% for sheet_name in stylesheets %} + {{- "@import url('" + sheet_name + "'); " }} +{% endfor %} +{% endblock %} diff --git a/reflex/.templates/web/jsconfig.json b/reflex/.templates/web/jsconfig.json index 36aa1a4dc..3c8a3257f 100644 --- a/reflex/.templates/web/jsconfig.json +++ b/reflex/.templates/web/jsconfig.json @@ -1,5 +1,8 @@ { "compilerOptions": { - "baseUrl": "." + "baseUrl": ".", + "paths": { + "@/*": ["public/*"] + } } -} +} \ No newline at end of file diff --git a/reflex/.templates/web/pages/_app.js b/reflex/.templates/web/pages/_app.js index 4039f5ae2..1f4873a57 100644 --- a/reflex/.templates/web/pages/_app.js +++ b/reflex/.templates/web/pages/_app.js @@ -4,7 +4,7 @@ import theme from "/utils/theme"; import { clientStorage, initialEvents, initialState, StateContext, EventLoopContext } from "/utils/context.js"; import { useEventLoop } from "utils/state"; -import '../styles/tailwind.css' +import '/styles/styles.css' const GlobalStyles = css` /* Hide the blue border around Chakra components. */ diff --git a/reflex/app.py b/reflex/app.py index 583f6ae03..77173a19c 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -612,8 +612,11 @@ class App(Base): for component in custom_components: all_imports.update(component.get_imports()) - # Compile the root document with base styles and fonts - compile_results.append(compiler.compile_document_root(self.stylesheets)) + # Compile the root stylesheet with base styles. + compile_results.append(compiler.compile_root_stylesheet(self.stylesheets)) + + # Compile the root document. + compile_results.append(compiler.compile_document_root()) # Compile the theme. compile_results.append(compiler.compile_theme(self.style)) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 5484d08bd..16df3c81a 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1,6 +1,8 @@ """Compiler for the reflex apps.""" from __future__ import annotations +import os +from pathlib import Path from typing import List, Set, Tuple, Type from reflex import constants @@ -118,6 +120,50 @@ def _compile_page( ) +def compile_root_stylesheet(stylesheets: List[str]) -> Tuple[str, str]: + """Compile the root stylesheet. + + Args: + stylesheets: The stylesheets to include in the root stylesheet. + + Returns: + The path and code of the compiled root stylesheet. + """ + output_path = utils.get_root_stylesheet_path() + + code = _compile_root_stylesheet(stylesheets) + + return output_path, code + + +def _compile_root_stylesheet(stylesheets: List[str]) -> str: + """Compile the root stylesheet. + + Args: + stylesheets: The stylesheets to include in the root stylesheet. + + Returns: + The compiled root stylesheet. + + Raises: + FileNotFoundError: If a specified stylesheet in assets directory does not exist. + """ + sheets = [constants.TAILWIND_ROOT_STYLE_PATH] + for stylesheet in stylesheets: + if not utils.is_valid_url(stylesheet): + # check if stylesheet provided exists. + stylesheet_full_path = ( + Path.cwd() / constants.APP_ASSETS_DIR / stylesheet.strip("/") + ) + if not os.path.exists(stylesheet_full_path): + raise FileNotFoundError( + f"The stylesheet file {stylesheet_full_path} does not exist." + ) + stylesheet = f"@/{stylesheet.strip('/')}" + sheets.append(stylesheet) if stylesheet not in sheets else None + return templates.STYLE.render(stylesheets=sheets) + + def _compile_components(components: Set[CustomComponent]) -> str: """Compile the components. @@ -162,12 +208,9 @@ def _compile_tailwind( ) -def compile_document_root(stylesheets: List[str]) -> Tuple[str, str]: +def compile_document_root() -> Tuple[str, str]: """Compile the document root. - Args: - stylesheets: The stylesheets to include in the document root. - Returns: The path and code of the compiled document root. """ @@ -175,8 +218,7 @@ def compile_document_root(stylesheets: List[str]) -> Tuple[str, str]: output_path = utils.get_page_path(constants.DOCUMENT_ROOT) # Create the document root. - document_root = utils.create_document_root(stylesheets) - + document_root = utils.create_document_root() # Compile the document root. code = _compile_document_root(document_root) return output_path, code @@ -279,5 +321,4 @@ def compile_tailwind( def purge_web_pages_dir(): """Empty out .web directory.""" - template_files = ["_app.js"] - utils.empty_dir(constants.WEB_PAGES_DIR, keep_files=template_files) + utils.empty_dir(constants.WEB_PAGES_DIR, keep_files=["_app.js"]) diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 064ff0bfd..5f1298522 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -77,3 +77,6 @@ COMPONENTS = get_template("web/pages/custom_component.js.jinja2") # Sitemap config file. SITEMAP_CONFIG = "module.exports = {config}".format + +# Code to render the root stylesheet. +STYLE = get_template("web/styles/styles.css.jinja2") diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 4e56c71bc..4b427d02e 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations import os from typing import Any, Dict, List, Optional, Set, Tuple, Type +from urllib.parse import urlparse from pydantic.fields import ModelField @@ -18,7 +19,6 @@ from reflex.components.base import ( Main, Meta, NextScript, - RawLink, Title, ) from reflex.components.component import Component, ComponentStyle, CustomComponent @@ -257,18 +257,14 @@ def compile_custom_component( ) -def create_document_root(stylesheets: List[str]) -> Component: +def create_document_root() -> Component: """Create the document root. - Args: - stylesheets: The list of stylesheets to include in the document root. - Returns: The document root. """ - sheets = [RawLink.create(rel="stylesheet", href=href) for href in stylesheets] return Html.create( - DocumentHead.create(*sheets), + DocumentHead.create(), Body.create( ColorModeScript.create(), Main.create(), @@ -324,6 +320,17 @@ def get_theme_path() -> str: return os.path.join(constants.WEB_UTILS_DIR, constants.THEME + constants.JS_EXT) +def get_root_stylesheet_path() -> str: + """Get the path of the app root file. + + Returns: + The path of the app root file. + """ + return os.path.join( + constants.STYLES_DIR, constants.STYLESHEET_ROOT + constants.CSS_EXT + ) + + def get_context_path() -> str: """Get the path of the context / initial state file. @@ -415,3 +422,16 @@ def empty_dir(path: str, keep_files: Optional[List[str]] = None): for element in directory_contents: if element not in keep_files: path_ops.rm(os.path.join(path, element)) + + +def is_valid_url(url) -> bool: + """Check if a url is valid. + + Args: + url: The Url to check. + + Returns: + Whether url is valid. + """ + result = urlparse(url) + return all([result.scheme, result.netloc]) diff --git a/reflex/constants.py b/reflex/constants.py index d81063107..c2d2fc95a 100644 --- a/reflex/constants.py +++ b/reflex/constants.py @@ -126,10 +126,14 @@ WEB_STATIC_DIR = os.path.join(WEB_DIR, STATIC_DIR) WEB_UTILS_DIR = os.path.join(WEB_DIR, UTILS_DIR) # The directory where the assets are located. WEB_ASSETS_DIR = os.path.join(WEB_DIR, "public") +# The directory where styles are located. +STYLES_DIR = os.path.join(WEB_DIR, "styles") # The Tailwind config. TAILWIND_CONFIG = os.path.join(WEB_DIR, "tailwind.config.js") # Default Tailwind content paths TAILWIND_CONTENT = ["./pages/**/*.{js,ts,jsx,tsx}"] +# Relative tailwind style path to root stylesheet in STYLES_DIR. +TAILWIND_ROOT_STYLE_PATH = "./tailwind.css" # The NextJS config file NEXT_CONFIG_FILE = "next.config.js" # The sitemap config file. @@ -148,6 +152,8 @@ ENV_JSON = os.path.join(WEB_DIR, "env.json") JS_EXT = ".js" # The extension for python files. PY_EXT = ".py" +# The extension for css files. +CSS_EXT = ".css" # The expected variable name where the rx.App is stored. APP_VAR = "app" # The expected variable name where the API object is stored for deployment. @@ -172,6 +178,10 @@ HYDRATE = "hydrate" IS_HYDRATED = "is_hydrated" # The name of the index page. INDEX_ROUTE = "index" +# The name of the app root page. +APP_ROOT = "_app" +# The root stylesheet filename. +STYLESHEET_ROOT = "styles" # The name of the document root page. DOCUMENT_ROOT = "_document" # The name of the theme page. diff --git a/tests/compiler/test_compiler.py b/tests/compiler/test_compiler.py index 376067060..0e51fd67d 100644 --- a/tests/compiler/test_compiler.py +++ b/tests/compiler/test_compiler.py @@ -1,8 +1,9 @@ +import os from typing import List, Set import pytest -from reflex.compiler import utils +from reflex.compiler import compiler, utils from reflex.utils import imports from reflex.vars import ImportVar @@ -106,22 +107,56 @@ def test_compile_imports(import_dict: imports.ImportDict, test_dicts: List[dict] assert import_dict["rest"] == test_dict["rest"] -# @pytest.mark.parametrize( -# "name,value,output", -# [ -# ("foo", "bar", 'const foo = "bar"'), -# ("num", 1, "const num = 1"), -# ("check", False, "const check = false"), -# ("arr", [1, 2, 3], "const arr = [1, 2, 3]"), -# ("obj", {"foo": "bar"}, 'const obj = {"foo": "bar"}'), -# ], -# ) -# def test_compile_constant_declaration(name: str, value: str, output: str): -# """Test the compile_constant_declaration function. +def test_compile_stylesheets(tmp_path, mocker): + """Test that stylesheets compile correctly. -# Args: -# name: The name of the constant. -# value: The value of the constant. -# output: The expected output. -# """ -# assert utils.compile_constant_declaration(name, value) == output + Args: + tmp_path: The test directory. + mocker: Pytest mocker object. + """ + project = tmp_path / "test_project" + project.mkdir() + + assets_dir = project / "assets" + assets_dir.mkdir() + + (assets_dir / "styles.css").touch() + + 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", + "https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css", + ] + + assert compiler.compile_root_stylesheet(stylesheets) == ( + os.path.join(".web", "styles", "styles.css"), + 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('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css'); \n", + ) + + +def test_compile_nonexistent_stylesheet(tmp_path, mocker): + """Test that an error is thrown for non-existent stylesheets. + + Args: + tmp_path: The test directory. + mocker: Pytest mocker object. + """ + project = tmp_path / "test_project" + project.mkdir() + + assets_dir = project / "assets" + assets_dir.mkdir() + + mocker.patch("reflex.compiler.compiler.Path.cwd", return_value=project) + + stylesheets = ["/styles.css"] + + with pytest.raises(FileNotFoundError): + compiler.compile_root_stylesheet(stylesheets)