diff --git a/pyi_hashes.json b/pyi_hashes.json index 69c4ff61e7b..a1140c49a3b 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,5 +1,5 @@ { - "reflex/__init__.pyi": "8a6d2350e96659846436792a5c7b772b", + "reflex/__init__.pyi": "d7767c4fe815246a4409359da60aac25", "reflex/components/__init__.pyi": "76ba0a12cd3a7ba5ab6341a3ae81551f", "reflex/components/base/__init__.pyi": "e9aaf47be1e1977eacee97b880c8f7de", "reflex/components/base/app_wrap.pyi": "1d0e224e2d4b0538b19c0a038284e9b2", @@ -56,7 +56,7 @@ "reflex/components/radix/primitives/progress.pyi": "98b4add410a80a353ab503ad577169c2", "reflex/components/radix/primitives/slider.pyi": "573837a7d8d90deaf57c911faffed254", "reflex/components/radix/themes/__init__.pyi": "a15f9464ad99f248249ffa8e6deea4cf", - "reflex/components/radix/themes/base.pyi": "1f0740d3165100c24e6bb4792aa81571", + "reflex/components/radix/themes/base.pyi": "526db93a3f52bb00ad220f8744eba797", "reflex/components/radix/themes/color_mode.pyi": "f7515dccd1e315dc28a3cbbe2eabe7ff", "reflex/components/radix/themes/components/__init__.pyi": "87bb9ffff641928562da1622d2ca5993", "reflex/components/radix/themes/components/alert_dialog.pyi": "9f19bcdb4588a7f76596d142a0ac0950", diff --git a/reflex/.templates/jinja/app/rxconfig.py.jinja2 b/reflex/.templates/jinja/app/rxconfig.py.jinja2 index 788096c5142..73e70a67c03 100644 --- a/reflex/.templates/jinja/app/rxconfig.py.jinja2 +++ b/reflex/.templates/jinja/app/rxconfig.py.jinja2 @@ -2,4 +2,5 @@ import reflex as rx config = rx.Config( app_name="{{ app_name }}", + plugins=[rx.plugins.TailwindV3Plugin()], ) diff --git a/reflex/.templates/jinja/web/tailwind.config.js.jinja2 b/reflex/.templates/jinja/web/tailwind.config.js.jinja2 deleted file mode 100644 index 72993478bdb..00000000000 --- a/reflex/.templates/jinja/web/tailwind.config.js.jinja2 +++ /dev/null @@ -1,66 +0,0 @@ -{# Helper macro to render JS objects and arrays #} -{% macro render_js(val, indent=2, level=0) -%} -{%- set space = ' ' * (indent * level) -%} -{%- set next_space = ' ' * (indent * (level + 1)) -%} - -{%- if val is mapping -%} -{ -{%- for k, v in val.items() %} -{{ next_space }}{{ k if k is string and k.isidentifier() else k|tojson }}: {{ render_js(v, indent, level + 1) }}{{ "," if not loop.last }} -{%- endfor %} -{{ space }}} -{%- elif val is iterable and val is not string -%} -[ -{%- for item in val %} -{{ next_space }}{{ render_js(item, indent, level + 1) }}{{ "," if not loop.last }} -{%- endfor %} -{{ space }}] -{%- else -%} -{{ val | tojson }} -{%- endif -%} -{%- endmacro %} - -{# Extract destructured imports from plugin dicts only #} -{%- set imports = [] %} -{%- for plugin in plugins if plugin is mapping and plugin.import is defined %} - {%- set _ = imports.append(plugin.import) %} -{%- endfor %} - -/** @type {import('tailwindcss').Config} */ -{%- for imp in imports %} -const { {{ imp.name }} } = require({{ imp.from | tojson }}); -{%- endfor %} - -module.exports = { - content: {{ render_js(content) }}, - theme: {{ render_js(theme) }}, - {% if darkMode is defined %}darkMode: {{ darkMode | tojson }},{% endif %} - {% if corePlugins is defined %}corePlugins: {{ render_js(corePlugins) }},{% endif %} - {% if important is defined %}important: {{ important | tojson }},{% endif %} - {% if prefix is defined %}prefix: {{ prefix | tojson }},{% endif %} - {% if separator is defined %}separator: {{ separator | tojson }},{% endif %} - {% if presets is defined %} - presets: [ - {% for preset in presets %} - require({{ preset | tojson }}){{ "," if not loop.last }} - {% endfor %} - ], - {% endif %} - plugins: [ - {% for plugin in plugins %} - {% if plugin is mapping %} - {% if plugin.call is defined %} - {{ plugin.call }}( - {%- if plugin.args is defined -%} - {{ render_js(plugin.args) }} - {%- endif -%} - ){{ "," if not loop.last }} - {% else %} - require({{ plugin.name | tojson }}){{ "," if not loop.last }} - {% endif %} - {% else %} - require({{ plugin | tojson }}){{ "," if not loop.last }} - {% endif %} - {% endfor %} - ] -}; diff --git a/reflex/.templates/web/postcss.config.js b/reflex/.templates/web/postcss.config.js index a5a6ff85820..616a3624843 100644 --- a/reflex/.templates/web/postcss.config.js +++ b/reflex/.templates/web/postcss.config.js @@ -1,7 +1,6 @@ module.exports = { plugins: { "postcss-import": {}, - tailwindcss: {}, autoprefixer: {}, }, }; diff --git a/reflex/.templates/web/styles/tailwind.css b/reflex/.templates/web/styles/tailwind.css deleted file mode 100644 index e1c383749de..00000000000 --- a/reflex/.templates/web/styles/tailwind.css +++ /dev/null @@ -1,6 +0,0 @@ -@import "tailwindcss/base"; - -@import "@radix-ui/themes/styles.css"; - -@tailwind components; -@tailwind utilities; diff --git a/reflex/__init__.py b/reflex/__init__.py index 8e84ec98db3..ada5642b914 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -361,6 +361,7 @@ "vars", "config", "compiler", + "plugins", } _SUBMOD_ATTRS: dict = _MAPPING getattr, __dir__, __all__ = lazy_loader.attach( diff --git a/reflex/app.py b/reflex/app.py index 49e04e43bff..ba4d427922e 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -18,7 +18,7 @@ from pathlib import Path from timeit import default_timer as timer from types import SimpleNamespace -from typing import TYPE_CHECKING, Any, BinaryIO, get_args, get_type_hints +from typing import TYPE_CHECKING, Any, BinaryIO, ParamSpec, get_args, get_type_hints from fastapi import FastAPI from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn @@ -314,6 +314,9 @@ def merged_with(self, other: UnevaluatedPage) -> UnevaluatedPage: ) +P = ParamSpec("P") + + @dataclasses.dataclass() class App(MiddlewareMixin, LifespanMixin): """The main Reflex app that encapsulates the backend and frontend. @@ -620,10 +623,12 @@ def __call__(self) -> ASGIApp: compile_future = concurrent.futures.ThreadPoolExecutor(max_workers=1).submit( self._compile ) - compile_future.add_done_callback( + + def callback(f: concurrent.futures.Future): # Force background compile errors to print eagerly - lambda f: f.result() - ) + return f.result() + + compile_future.add_done_callback(callback) # Wait for the compile to finish to ensure all optional endpoints are mounted. compile_future.result() @@ -1029,11 +1034,6 @@ def _get_frontend_packages(self, imports: dict[str, set[ImportVar]]): frontend_packages = get_config().frontend_packages _frontend_packages = [] for package in frontend_packages: - if package in (get_config().tailwind or {}).get("plugins", []): - console.warn( - f"Tailwind packages are inferred from 'plugins', remove `{package}` from `frontend_packages`" - ) - continue if package in page_imports: console.warn( f"React packages and their dependencies are inferred from Component.library and Component.lib_dependencies, remove `{package}` from `frontend_packages`" @@ -1166,6 +1166,7 @@ def _compile(self, export: bool = False, dry_run: bool = False): Raises: ReflexRuntimeError: When any page uses state, but no rx.State subclass is defined. + FileNotFoundError: When a plugin requires a file that does not exist. """ from reflex.utils.exceptions import ReflexRuntimeError @@ -1380,10 +1381,20 @@ def memoized_toast_provider(): ExecutorSafeFunctions.STATE = self._state - with console.timing("Compile to Javascript"), executor as executor: - result_futures: list[concurrent.futures.Future[tuple[str, str]]] = [] + modify_files_tasks: list[tuple[str, str, Callable[[str], str]]] = [] - def _submit_work(fn: Callable[..., tuple[str, str]], *args, **kwargs): + with console.timing("Compile to Javascript"), executor as executor: + result_futures: list[ + concurrent.futures.Future[ + list[tuple[str, str]] | tuple[str, str] | None + ] + ] = [] + + def _submit_work( + fn: Callable[P, list[tuple[str, str]] | tuple[str, str] | None], + *args: P.args, + **kwargs: P.kwargs, + ): f = executor.submit(fn, *args, **kwargs) f.add_done_callback(lambda _: progress.advance(task)) result_futures.append(f) @@ -1401,20 +1412,26 @@ def _submit_work(fn: Callable[..., tuple[str, str]], *args, **kwargs): # Compile the theme. _submit_work(compile_theme, self.style) - # Compile the Tailwind config. - if config.tailwind is not None: - config.tailwind["content"] = config.tailwind.get( - "content", constants.Tailwind.CONTENT + for plugin in config.plugins: + plugin.pre_compile( + add_save_task=_submit_work, + add_modify_task=( + lambda *args, plugin=plugin: modify_files_tasks.append( + ( + plugin.__class__.__module__ + plugin.__class__.__name__, + *args, + ) + ) + ), ) - _submit_work(compiler.compile_tailwind, config.tailwind) - else: - _submit_work(compiler.remove_tailwind_from_postcss) # Wait for all compilation tasks to complete. - compile_results.extend( - future.result() - for future in concurrent.futures.as_completed(result_futures) - ) + for future in concurrent.futures.as_completed(result_futures): + if (result := future.result()) is not None: + if isinstance(result, list): + compile_results.extend(result) + else: + compile_results.append(result) app_root = self._app_root(app_wrappers=app_wrappers) @@ -1481,9 +1498,45 @@ def _submit_work(fn: Callable[..., tuple[str, str]], *args, **kwargs): # Remove pages that are no longer in the app. p.unlink() + output_mapping: dict[Path, str] = {} + for output_path, code in compile_results: + path = compiler_utils.resolve_path_of_web_dir(output_path) + if path in output_mapping: + console.warn( + f"Path {path} has two different outputs. The first one will be used." + ) + else: + output_mapping[path] = code + + for plugin in config.plugins: + for static_file_path, content in plugin.get_static_assets(): + path = compiler_utils.resolve_path_of_web_dir(static_file_path) + if path in output_mapping: + console.warn( + f"Plugin {plugin.__class__.__name__} is trying to write to {path} but it already exists. The plugin file will be ignored." + ) + else: + output_mapping[path] = ( + content.decode("utf-8") + if isinstance(content, bytes) + else content + ) + + for plugin_name, file_path, modify_fn in modify_files_tasks: + path = compiler_utils.resolve_path_of_web_dir(file_path) + file_content = output_mapping.get(path) + if file_content is None: + if path.exists(): + file_content = path.read_text() + else: + raise FileNotFoundError( + f"Plugin {plugin_name} is trying to modify {path} but it does not exist." + ) + output_mapping[path] = modify_fn(file_content) + with console.timing("Write to Disk"): - for output_path, code in compile_results: - compiler_utils.write_page(output_path, code) + for output_path, code in output_mapping.items(): + compiler_utils.write_file(output_path, code) def _write_stateful_pages_marker(self): """Write list of routes that create dynamic states for the backend to use later.""" diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 74b4fa877f2..7dbd705edca 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -223,6 +223,9 @@ def _validate_stylesheet(stylesheet_full_path: Path, assets_app_path: Path) -> N ) +RADIX_THEMES_STYLESHEET = "@radix-ui/themes/styles.css" + + def _compile_root_stylesheet(stylesheets: list[str]) -> str: """Compile the root stylesheet. @@ -235,12 +238,12 @@ def _compile_root_stylesheet(stylesheets: list[str]) -> str: Raises: FileNotFoundError: If a specified stylesheet in assets directory does not exist. """ - # Add tailwind css if enabled. - sheets = ( - [constants.Tailwind.ROOT_STYLE_PATH] - if get_config().tailwind is not None - else [] - ) + # Add stylesheets from plugins. + sheets = [RADIX_THEMES_STYLESHEET] + [ + sheet + for plugin in get_config().plugins + for sheet in plugin.get_stylesheet_paths() + ] failed_to_import_sass = False assets_app_path = Path.cwd() / constants.Dirs.APP_ASSETS @@ -451,22 +454,6 @@ def get_shared_components_recursive(component: BaseComponent): ) -def _compile_tailwind( - config: dict, -) -> str: - """Compile the Tailwind config. - - Args: - config: The Tailwind config. - - Returns: - The compiled Tailwind config. - """ - return templates.TAILWIND_CONFIG.render( - **config, - ) - - def compile_document_root( head_components: list[Component], html_lang: str | None = None, @@ -613,44 +600,6 @@ def compile_stateful_components( return output_path, code, page_components -def compile_tailwind( - config: dict, -): - """Compile the Tailwind config. - - Args: - config: The Tailwind config. - - Returns: - The compiled Tailwind config. - """ - # Get the path for the output file. - output_path = str((get_web_dir() / constants.Tailwind.CONFIG).absolute()) - - # Compile the config. - code = _compile_tailwind(config) - return output_path, code - - -def remove_tailwind_from_postcss() -> tuple[str, str]: - """If tailwind is not to be used, remove it from postcss.config.js. - - Returns: - The path and code of the compiled postcss.config.js. - """ - # Get the path for the output file. - output_path = str(get_web_dir() / constants.Dirs.POSTCSS_JS) - - code = [ - line - for line in Path(output_path).read_text().splitlines(keepends=True) - if "tailwindcss: " not in line - ] - - # Compile the config. - return output_path, "".join(code) - - def purge_web_pages_dir(): """Empty out .web/pages directory.""" if not is_prod_mode() and environment.REFLEX_PERSIST_WEB_DIR.get(): diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index e85e5fe6df9..a06f20d0427 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -98,6 +98,18 @@ def get_template(name: str) -> Template: return ReflexJinjaEnvironment().get_template(name=name) +def from_string(source: str) -> Template: + """Get render function that work with a template. + + Args: + source: The template source. + + Returns: + A render function. + """ + return ReflexJinjaEnvironment().from_string(source=source) + + # Template for the Reflex config file. RXCONFIG = get_template("app/rxconfig.py.jinja2") @@ -113,9 +125,6 @@ def get_template(name: str) -> Template: # Template for the context file. CONTEXT = get_template("web/utils/context.js.jinja2") -# Template for Tailwind config. -TAILWIND_CONFIG = get_template("web/tailwind.config.js.jinja2") - # Template to render a component tag. COMPONENT = get_template("web/pages/component.js.jinja2") diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index ad7106c0902..1766090135d 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -500,7 +500,23 @@ def add_meta( return page -def write_page(path: str | Path, code: str): +def resolve_path_of_web_dir(path: str | Path) -> Path: + """Get the path under the web directory. + + Args: + path: The path to get. It can be a relative or absolute path. + + Returns: + The path under the web directory. + """ + path = Path(path) + web_dir = get_web_dir() + if path.is_relative_to(web_dir): + return path.absolute() + return (web_dir / path).absolute() + + +def write_file(path: str | Path, code: str): """Write the given code to the given path. Args: @@ -508,7 +524,7 @@ def write_page(path: str | Path, code: str): code: The code to write. """ path = Path(path) - path_ops.mkdir(path.parent) + path.parent.mkdir(parents=True, exist_ok=True) if path.exists() and path.read_text(encoding="utf-8") == code: return path.write_text(code, encoding="utf-8") diff --git a/reflex/components/radix/themes/base.py b/reflex/components/radix/themes/base.py index 490d8f49171..eb2b236d183 100644 --- a/reflex/components/radix/themes/base.py +++ b/reflex/components/radix/themes/base.py @@ -7,7 +7,6 @@ from reflex.components import Component from reflex.components.core.breakpoints import Responsive from reflex.components.tags import Tag -from reflex.config import get_config from reflex.utils.imports import ImportDict, ImportVar from reflex.vars.base import Var @@ -241,17 +240,9 @@ def add_imports(self) -> ImportDict | list[ImportDict]: Returns: The import dict. """ - _imports: ImportDict = { + return { "$/utils/theme.js": [ImportVar(tag="theme", is_default=True)], } - if get_config().tailwind is None: - # When tailwind is disabled, import the radix-ui styles directly because they will - # not be included in the tailwind.css file. - _imports[""] = ImportVar( - tag="@radix-ui/themes/styles.css", - install=False, - ) - return _imports def _render(self, props: dict[str, Any] | None = None) -> Tag: tag = super()._render(props) diff --git a/reflex/config.py b/reflex/config.py index 124d87e762b..6ce3b873bab 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -35,6 +35,7 @@ from reflex import constants from reflex.base import Base from reflex.constants.base import LogLevel +from reflex.plugins import Plugin, TailwindV3Plugin, TailwindV4Plugin from reflex.utils import console from reflex.utils.exceptions import ConfigError, EnvironmentVarValueError from reflex.utils.types import ( @@ -895,6 +896,9 @@ class Config: # pyright: ignore [reportIncompatibleVariableOverride] # Extra overlay function to run after the app is built. Formatted such that `from path_0.path_1... import path[-1]`, and calling it with no arguments would work. For example, "reflex.components.moment.moment". extra_overlay_function: str | None = None + # List of plugins to use in the app. + plugins: list[Plugin] = [] + _prefixes: ClassVar[list[str]] = ["REFLEX_"] def __init__(self, *args, **kwargs): @@ -939,6 +943,23 @@ def __init__(self, *args, **kwargs): self._non_default_attributes.update(kwargs) self._replace_defaults(**kwargs) + if self.tailwind is not None and not any( + isinstance(plugin, (TailwindV3Plugin, TailwindV4Plugin)) + for plugin in self.plugins + ): + console.deprecate( + "Inferring tailwind usage", + reason=""" + +If you are using tailwind, add `rx.plugins.TailwindV3Plugin()` to the `plugins=[]` in rxconfig.py. + +If you are not using tailwind, set `tailwind` to `None` in rxconfig.py.""", + deprecation_version="0.7.13", + removal_version="0.8.0", + dedupe=True, + ) + self.plugins.append(TailwindV3Plugin()) + if ( self.state_manager_mode == constants.StateManagerMode.REDIS and not self.redis_url diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py index b765bb5c064..19d764f76af 100644 --- a/reflex/constants/__init__.py +++ b/reflex/constants/__init__.py @@ -59,7 +59,6 @@ RouteVar, ) from .state import StateManagerMode -from .style import Tailwind __all__ = [ "ALEMBIC_CONFIG", @@ -115,6 +114,5 @@ "RouteVar", "SocketEvent", "StateManagerMode", - "Tailwind", "Templates", ] diff --git a/reflex/constants/style.py b/reflex/constants/style.py deleted file mode 100644 index 5b31ce9b369..00000000000 --- a/reflex/constants/style.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Style constants.""" - -from types import SimpleNamespace - - -class Tailwind(SimpleNamespace): - """Tailwind constants.""" - - # The Tailwindcss version - VERSION = "tailwindcss@3.4.17" - # The Tailwind config. - CONFIG = "tailwind.config.js" - # Default Tailwind content paths - CONTENT = ["./pages/**/*.{js,ts,jsx,tsx}", "./utils/**/*.{js,ts,jsx,tsx}"] - # Relative tailwind style path to root stylesheet in Dirs.STYLES. - ROOT_STYLE_PATH = "./tailwind.css" diff --git a/reflex/plugins/__init__.py b/reflex/plugins/__init__.py new file mode 100644 index 00000000000..dfde399c097 --- /dev/null +++ b/reflex/plugins/__init__.py @@ -0,0 +1,7 @@ +"""Reflex Plugin System.""" + +from .base import CommonContext as CommonContext +from .base import Plugin as Plugin +from .base import PreCompileContext as PreCompileContext +from .tailwind_v3 import Plugin as TailwindV3Plugin +from .tailwind_v4 import Plugin as TailwindV4Plugin diff --git a/reflex/plugins/base.py b/reflex/plugins/base.py new file mode 100644 index 00000000000..fc01d27f9cd --- /dev/null +++ b/reflex/plugins/base.py @@ -0,0 +1,101 @@ +"""Base class for all plugins.""" + +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import ParamSpec, Protocol, TypedDict + +from typing_extensions import Unpack + + +class CommonContext(TypedDict): + """Common context for all plugins.""" + + +P = ParamSpec("P") + + +class AddTaskProtcol(Protocol): + """Protocol for adding a task to the pre-compile context.""" + + def __call__( + self, + task: Callable[P, list[tuple[str, str]] | tuple[str, str] | None], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> None: + """Add a task to the pre-compile context. + + Args: + task: The task to add. + args: The arguments to pass to the task + kwargs: The keyword arguments to pass to the task + """ + + +class PreCompileContext(CommonContext): + """Context for pre-compile hooks.""" + + add_save_task: AddTaskProtcol + add_modify_task: Callable[[str, Callable[[str], str]], None] + + +class Plugin: + """Base class for all plugins.""" + + def get_frontend_development_dependencies( + self, **context: Unpack[CommonContext] + ) -> list[str] | set[str] | tuple[str, ...]: + """Get the NPM packages required by the plugin for development. + + Args: + context: The context for the plugin. + + Returns: + A list of packages required by the plugin for development. + """ + return [] + + def get_frontend_dependencies( + self, **context: Unpack[CommonContext] + ) -> list[str] | set[str] | tuple[str, ...]: + """Get the NPM packages required by the plugin. + + Args: + context: The context for the plugin. + + Returns: + A list of packages required by the plugin. + """ + return [] + + def get_static_assets( + self, **context: Unpack[CommonContext] + ) -> Sequence[tuple[Path, str | bytes]]: + """Get the static assets required by the plugin. + + Args: + context: The context for the plugin. + + Returns: + A list of static assets required by the plugin. + """ + return [] + + def get_stylesheet_paths(self, **context: Unpack[CommonContext]) -> Sequence[str]: + """Get the paths to the stylesheets required by the plugin relative to the styles directory. + + Args: + context: The context for the plugin. + + Returns: + A list of paths to the stylesheets required by the plugin. + """ + return [] + + def pre_compile(self, **context: Unpack[PreCompileContext]) -> None: + """Called before the compilation of the plugin. + + Args: + context: The context for the plugin. + """ diff --git a/reflex/plugins/tailwind_v3.py b/reflex/plugins/tailwind_v3.py new file mode 100644 index 00000000000..8fb1de9b205 --- /dev/null +++ b/reflex/plugins/tailwind_v3.py @@ -0,0 +1,255 @@ +"""Base class for all plugins.""" + +from pathlib import Path +from types import SimpleNamespace + +from reflex.constants.base import Dirs +from reflex.constants.compiler import Ext, PageNames +from reflex.plugins.base import Plugin as PluginBase +from reflex.utils.decorator import once + + +class Constants(SimpleNamespace): + """Tailwind constants.""" + + # The Tailwindcss version + VERSION = "tailwindcss@3.4.17" + # The Tailwind config. + CONFIG = "tailwind.config.js" + # Default Tailwind content paths + CONTENT = ["./pages/**/*.{js,ts,jsx,tsx}", "./utils/**/*.{js,ts,jsx,tsx}"] + # Relative tailwind style path to root stylesheet in Dirs.STYLES. + ROOT_STYLE_PATH = "./tailwind.css" + + # Content of the style content. + ROOT_STYLE_CONTENT = """ +@import "tailwindcss/base"; + +@import url('{radix_url}'); + +@tailwind components; +@tailwind utilities; +""" + + # The default tailwind css. + TAILWIND_CSS = "@import url('./tailwind.css');" + + +@once +def tailwind_config_js_template(): + """Get the Tailwind config template. + + Returns: + The Tailwind config template. + """ + from reflex.compiler.templates import from_string + + source = r""" +{# Helper macro to render JS objects and arrays #} +{% macro render_js(val, indent=2, level=0) -%} +{%- set space = ' ' * (indent * level) -%} +{%- set next_space = ' ' * (indent * (level + 1)) -%} + +{%- if val is mapping -%} +{ +{%- for k, v in val.items() %} +{{ next_space }}{{ k if k is string and k.isidentifier() else k|tojson }}: {{ render_js(v, indent, level + 1) }}{{ "," if not loop.last }} +{%- endfor %} +{{ space }}} +{%- elif val is iterable and val is not string -%} +[ +{%- for item in val %} +{{ next_space }}{{ render_js(item, indent, level + 1) }}{{ "," if not loop.last }} +{%- endfor %} +{{ space }}] +{%- else -%} +{{ val | tojson }} +{%- endif -%} +{%- endmacro %} + +{# Extract destructured imports from plugin dicts only #} +{%- set imports = [] %} +{%- for plugin in plugins if plugin is mapping and plugin.import is defined %} + {%- set _ = imports.append(plugin.import) %} +{%- endfor %} + +/** @type {import('tailwindcss').Config} */ +{%- for imp in imports %} +const { {{ imp.name }} } = require({{ imp.from | tojson }}); +{%- endfor %} + +module.exports = { + content: {{ render_js(content) }}, + theme: {{ render_js(theme) }}, + {% if darkMode is defined %}darkMode: {{ darkMode | tojson }},{% endif %} + {% if corePlugins is defined %}corePlugins: {{ render_js(corePlugins) }},{% endif %} + {% if important is defined %}important: {{ important | tojson }},{% endif %} + {% if prefix is defined %}prefix: {{ prefix | tojson }},{% endif %} + {% if separator is defined %}separator: {{ separator | tojson }},{% endif %} + {% if presets is defined %} + presets: [ + {% for preset in presets %} + require({{ preset | tojson }}){{ "," if not loop.last }} + {% endfor %} + ], + {% endif %} + plugins: [ + {% for plugin in plugins %} + {% if plugin is mapping %} + {% if plugin.call is defined %} + {{ plugin.call }}( + {%- if plugin.args is defined -%} + {{ render_js(plugin.args) }} + {%- endif -%} + ){{ "," if not loop.last }} + {% else %} + require({{ plugin.name | tojson }}){{ "," if not loop.last }} + {% endif %} + {% else %} + require({{ plugin | tojson }}){{ "," if not loop.last }} + {% endif %} + {% endfor %} + ] +}; +""" + + return from_string(source) + + +def compile_config( + config: dict, +): + """Compile the Tailwind config. + + Args: + config: The Tailwind config. + + Returns: + The compiled Tailwind config. + """ + return Constants.CONFIG, tailwind_config_js_template().render( + **config, + ) + + +def compile_root_style(): + """Compile the Tailwind root style. + + Returns: + The compiled Tailwind root style. + """ + from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + + return str( + Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH + ), Constants.ROOT_STYLE_CONTENT.format( + radix_url=RADIX_THEMES_STYLESHEET, + ) + + +def _index_of_element_that_has(haystack: list[str], needle: str) -> int | None: + return next( + (i for i, line in enumerate(haystack) if needle in line), + None, + ) + + +def add_tailwind_to_postcss_config(postcss_file_content: str) -> str: + """Add tailwind to the postcss config. + + Args: + postcss_file_content: The content of the postcss config file. + + Returns: + The modified postcss config file content. + """ + from reflex.constants import Dirs + + postcss_file_lines = postcss_file_content.splitlines() + + if _index_of_element_that_has(postcss_file_lines, "tailwindcss") is not None: + return postcss_file_content + + line_with_postcss_plugins = _index_of_element_that_has( + postcss_file_lines, "plugins" + ) + if not line_with_postcss_plugins: + print( # noqa: T201 + f"Could not find line with 'plugins' in {Dirs.POSTCSS_JS}. " + "Please make sure the file exists and is valid." + ) + return postcss_file_content + + postcss_import_line = _index_of_element_that_has( + postcss_file_lines, '"postcss-import"' + ) + postcss_file_lines.insert( + (postcss_import_line or line_with_postcss_plugins) + 1, "tailwindcss: {}," + ) + + return "\n".join(postcss_file_lines) + + +def add_tailwind_to_css_file(css_file_content: str) -> str: + """Add tailwind to the css file. + + Args: + css_file_content: The content of the css file. + + Returns: + The modified css file content. + """ + from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + + if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content: + return css_file_content + if RADIX_THEMES_STYLESHEET not in css_file_content: + print( # noqa: T201 + f"Could not find line with '{RADIX_THEMES_STYLESHEET}' in {Dirs.STYLES}. " + "Please make sure the file exists and is valid." + ) + return css_file_content + return css_file_content.replace( + f"@import url('{RADIX_THEMES_STYLESHEET}');", + Constants.TAILWIND_CSS, + ) + + +class Plugin(PluginBase): + """Plugin for Tailwind CSS.""" + + def get_frontend_development_dependencies(self, **context) -> list[str]: + """Get the packages required by the plugin. + + Args: + **context: The context for the plugin. + + Returns: + A list of packages required by the plugin. + """ + from reflex.config import get_config + + config = get_config() + return [ + plugin if isinstance(plugin, str) else plugin.get("name") + for plugin in (config.tailwind or {}).get("plugins", []) + ] + [Constants.VERSION] + + def pre_compile(self, **context): + """Pre-compile the plugin. + + Args: + context: The context for the plugin. + """ + from reflex.config import get_config + + config = get_config().tailwind or {} + + config["content"] = config.get("content", Constants.CONTENT) + context["add_save_task"](compile_config, config) + context["add_save_task"](compile_root_style) + context["add_modify_task"](Dirs.POSTCSS_JS, add_tailwind_to_postcss_config) + context["add_modify_task"]( + str(Path(Dirs.STYLES) / (PageNames.STYLESHEET_ROOT + Ext.CSS)), + add_tailwind_to_css_file, + ) diff --git a/reflex/plugins/tailwind_v4.py b/reflex/plugins/tailwind_v4.py new file mode 100644 index 00000000000..30f1bc49ac0 --- /dev/null +++ b/reflex/plugins/tailwind_v4.py @@ -0,0 +1,257 @@ +"""Base class for all plugins.""" + +from pathlib import Path +from types import SimpleNamespace + +from reflex.constants.base import Dirs +from reflex.constants.compiler import Ext, PageNames +from reflex.plugins.base import Plugin as PluginBase +from reflex.utils.decorator import once + + +class Constants(SimpleNamespace): + """Tailwind constants.""" + + # The Tailwindcss version + VERSION = "tailwindcss@4.1.7" + # The Tailwind config. + CONFIG = "tailwind.config.js" + # Default Tailwind content paths + CONTENT = ["./pages/**/*.{js,ts,jsx,tsx}", "./utils/**/*.{js,ts,jsx,tsx}"] + # Relative tailwind style path to root stylesheet in Dirs.STYLES. + ROOT_STYLE_PATH = "./tailwind.css" + + # Content of the style content. + ROOT_STYLE_CONTENT = """@layer theme, base, components, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/preflight.css" layer(base); +@import "{radix_url}" layer(components); +@import "tailwindcss/utilities.css" layer(utilities); +""" + + # The default tailwind css. + TAILWIND_CSS = "@import url('./tailwind.css');" + + +@once +def tailwind_config_js_template(): + """Get the Tailwind config template. + + Returns: + The Tailwind config template. + """ + from reflex.compiler.templates import from_string + + source = r""" +{# Helper macro to render JS objects and arrays #} +{% macro render_js(val, indent=2, level=0) -%} +{%- set space = ' ' * (indent * level) -%} +{%- set next_space = ' ' * (indent * (level + 1)) -%} + +{%- if val is mapping -%} +{ +{%- for k, v in val.items() %} +{{ next_space }}{{ k if k is string and k.isidentifier() else k|tojson }}: {{ render_js(v, indent, level + 1) }}{{ "," if not loop.last }} +{%- endfor %} +{{ space }}} +{%- elif val is iterable and val is not string -%} +[ +{%- for item in val %} +{{ next_space }}{{ render_js(item, indent, level + 1) }}{{ "," if not loop.last }} +{%- endfor %} +{{ space }}] +{%- else -%} +{{ val | tojson }} +{%- endif -%} +{%- endmacro %} + +{# Extract destructured imports from plugin dicts only #} +{%- set imports = [] %} +{%- for plugin in plugins if plugin is mapping and plugin.import is defined %} + {%- set _ = imports.append(plugin.import) %} +{%- endfor %} + +/** @type {import('tailwindcss').Config} */ +{%- for imp in imports %} +const { {{ imp.name }} } = require({{ imp.from | tojson }}); +{%- endfor %} + +module.exports = { + content: {{ render_js(content) }}, + theme: {{ render_js(theme) }}, + {% if darkMode is defined %}darkMode: {{ darkMode | tojson }},{% endif %} + {% if corePlugins is defined %}corePlugins: {{ render_js(corePlugins) }},{% endif %} + {% if important is defined %}important: {{ important | tojson }},{% endif %} + {% if prefix is defined %}prefix: {{ prefix | tojson }},{% endif %} + {% if separator is defined %}separator: {{ separator | tojson }},{% endif %} + {% if presets is defined %} + presets: [ + {% for preset in presets %} + require({{ preset | tojson }}){{ "," if not loop.last }} + {% endfor %} + ], + {% endif %} + plugins: [ + {% for plugin in plugins %} + {% if plugin is mapping %} + {% if plugin.call is defined %} + {{ plugin.call }}( + {%- if plugin.args is defined -%} + {{ render_js(plugin.args) }} + {%- endif -%} + ){{ "," if not loop.last }} + {% else %} + require({{ plugin.name | tojson }}){{ "," if not loop.last }} + {% endif %} + {% else %} + require({{ plugin | tojson }}){{ "," if not loop.last }} + {% endif %} + {% endfor %} + ] +}; +""" + + return from_string(source) + + +def compile_config( + config: dict, +): + """Compile the Tailwind config. + + Args: + config: The Tailwind config. + + Returns: + The compiled Tailwind config. + """ + return Constants.CONFIG, tailwind_config_js_template().render( + **config, + ) + + +def compile_root_style(): + """Compile the Tailwind root style. + + Returns: + The compiled Tailwind root style. + """ + from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + + return str( + Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH + ), Constants.ROOT_STYLE_CONTENT.format( + radix_url=RADIX_THEMES_STYLESHEET, + ) + + +def _index_of_element_that_has(haystack: list[str], needle: str) -> int | None: + return next( + (i for i, line in enumerate(haystack) if needle in line), + None, + ) + + +def add_tailwind_to_postcss_config(postcss_file_content: str) -> str: + """Add tailwind to the postcss config. + + Args: + postcss_file_content: The content of the postcss config file. + + Returns: + The modified postcss config file content. + """ + from reflex.constants import Dirs + + postcss_file_lines = postcss_file_content.splitlines() + + line_with_postcss_plugins = _index_of_element_that_has( + postcss_file_lines, "plugins" + ) + if not line_with_postcss_plugins: + print( # noqa: T201 + f"Could not find line with 'plugins' in {Dirs.POSTCSS_JS}. " + "Please make sure the file exists and is valid." + ) + return postcss_file_content + + plugins_to_remove = ['"postcss-import"', "tailwindcss", "autoprefixer"] + plugins_to_add = ['"@tailwindcss/postcss"'] + + for plugin in plugins_to_remove: + plugin_index = _index_of_element_that_has(postcss_file_lines, plugin) + if plugin_index is not None: + postcss_file_lines.pop(plugin_index) + + for plugin in plugins_to_add[::-1]: + if not _index_of_element_that_has(postcss_file_lines, plugin): + postcss_file_lines.insert( + line_with_postcss_plugins + 1, f" {plugin}: {{}}," + ) + + return "\n".join(postcss_file_lines) + + +def add_tailwind_to_css_file(css_file_content: str) -> str: + """Add tailwind to the css file. + + Args: + css_file_content: The content of the css file. + + Returns: + The modified css file content. + """ + from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + + if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content: + return css_file_content + if RADIX_THEMES_STYLESHEET not in css_file_content: + print( # noqa: T201 + f"Could not find line with '{RADIX_THEMES_STYLESHEET}' in {Dirs.STYLES}. " + "Please make sure the file exists and is valid." + ) + return css_file_content + return css_file_content.replace( + f"@import url('{RADIX_THEMES_STYLESHEET}');", + Constants.TAILWIND_CSS, + ) + + +class Plugin(PluginBase): + """Plugin for Tailwind CSS.""" + + def get_frontend_development_dependencies(self, **context) -> list[str]: + """Get the packages required by the plugin. + + Args: + **context: The context for the plugin. + + Returns: + A list of packages required by the plugin. + """ + from reflex.config import get_config + + config = get_config() + return [ + plugin if isinstance(plugin, str) else plugin.get("name") + for plugin in (config.tailwind or {}).get("plugins", []) + ] + [Constants.VERSION, "@tailwindcss/postcss@4.1.7"] + + def pre_compile(self, **context): + """Pre-compile the plugin. + + Args: + context: The context for the plugin. + """ + from reflex.config import get_config + + config = get_config().tailwind or {} + + config["content"] = config.get("content", Constants.CONTENT) + context["add_save_task"](compile_config, config) + context["add_save_task"](compile_root_style) + context["add_modify_task"](Dirs.POSTCSS_JS, add_tailwind_to_postcss_config) + context["add_modify_task"]( + str(Path(Dirs.STYLES) / (PageNames.STYLESHEET_ROOT + Ext.CSS)), + add_tailwind_to_css_file, + ) diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index a6f2f64df42..8277c2006f2 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -17,7 +17,6 @@ import shutil import sys import tempfile -import time import typing import zipfile from collections.abc import Callable, Sequence @@ -40,10 +39,7 @@ from reflex.config import Config, environment, get_config from reflex.utils import console, net, path_ops, processes, redir from reflex.utils.decorator import once -from reflex.utils.exceptions import ( - GeneratedCodeHasNoFunctionDefsError, - SystemPackageMissingError, -) +from reflex.utils.exceptions import SystemPackageMissingError from reflex.utils.format import format_library_name from reflex.utils.registry import get_npm_registry @@ -1317,47 +1313,42 @@ def install_frontend_packages(packages: set[str], config: Config): primary_package_manager = install_package_managers[0] fallbacks = install_package_managers[1:] - processes.run_process_with_fallbacks( - [primary_package_manager, "install", "--legacy-peer-deps"], + run_package_manager = functools.partial( + processes.run_process_with_fallbacks, fallbacks=fallbacks, analytics_enabled=True, - show_status_message="Installing base frontend packages", cwd=get_web_dir(), shell=constants.IS_WINDOWS, env=env, ) - if config.tailwind is not None: - processes.run_process_with_fallbacks( + run_package_manager( + [primary_package_manager, "install", "--legacy-peer-deps"], + show_status_message="Installing base frontend packages", + ) + + development_deps: set[str] = set() + for plugin in config.plugins: + development_deps.update(plugin.get_frontend_development_dependencies()) + packages.update(plugin.get_frontend_dependencies()) + + if development_deps: + run_package_manager( [ primary_package_manager, "add", "--legacy-peer-deps", "-d", - constants.Tailwind.VERSION, - *[ - plugin if isinstance(plugin, str) else plugin.get("name") - for plugin in (config.tailwind or {}).get("plugins", []) - ], + *development_deps, ], - fallbacks=fallbacks, - analytics_enabled=True, - show_status_message="Installing tailwind", - cwd=get_web_dir(), - shell=constants.IS_WINDOWS, - env=env, + show_status_message="Installing frontend development dependencies", ) # Install custom packages defined in frontend_packages - if len(packages) > 0: - processes.run_process_with_fallbacks( + if packages: + run_package_manager( [primary_package_manager, "add", "--legacy-peer-deps", *packages], - fallbacks=fallbacks, - analytics_enabled=True, show_status_message="Installing frontend packages from config and components", - cwd=get_web_dir(), - shell=constants.IS_WINDOWS, - env=env, ) @@ -1931,66 +1922,6 @@ def get_init_cli_prompt_options() -> list[Template]: ] -def initialize_main_module_index_from_generation(app_name: str, generation_hash: str): - """Overwrite the `index` function in the main module with reflex.build generated code. - - Args: - app_name: The name of the app. - generation_hash: The generation hash from reflex.build. - - Raises: - GeneratedCodeHasNoFunctionDefsError: If the fetched code has no function definitions - (the refactored reflex code is expected to have at least one root function defined). - """ - # Download the reflex code for the generation. - url = constants.Templates.REFLEX_BUILD_CODE_URL.format( - generation_hash=generation_hash - ) - resp = net.get(url) - while resp.status_code == httpx.codes.SERVICE_UNAVAILABLE: - console.debug("Waiting for the code to be generated...") - time.sleep(1) - resp = net.get(url) - resp.raise_for_status() - - # Determine the name of the last function, which renders the generated code. - defined_funcs = re.findall(r"def ([a-zA-Z_]+)\(", resp.text) - if not defined_funcs: - raise GeneratedCodeHasNoFunctionDefsError( - f"No function definitions found in generated code from {url!r}." - ) - render_func_name = defined_funcs[-1] - - def replace_content(_match: re.Match) -> str: - return "\n".join( - [ - resp.text, - "", - "def index() -> rx.Component:", - f" return {render_func_name}()", - "", - "", - ], - ) - - main_module_path = Path(app_name, app_name + constants.Ext.PY) - main_module_code = main_module_path.read_text() - - main_module_code = re.sub( - r"def index\(\).*:\n([^\n]\s+.*\n+)+", - replace_content, - main_module_code, - ) - # Make the app use light mode until flexgen enforces the conversion of - # tailwind colors to radix colors. - main_module_code = re.sub( - r"app\s*=\s*rx\.App\(\s*\)", - 'app = rx.App(theme=rx.theme(color_mode="light"))', - main_module_code, - ) - main_module_path.write_text(main_module_code) - - def format_address_width(address_width: str | None) -> int | None: """Cast address width to an int. diff --git a/tests/integration/test_tailwind.py b/tests/integration/test_tailwind.py index ec532e78e6e..53090050585 100644 --- a/tests/integration/test_tailwind.py +++ b/tests/integration/test_tailwind.py @@ -10,18 +10,19 @@ PARAGRAPH_TEXT = "Tailwind Is Cool" PARAGRAPH_CLASS_NAME = "text-red-500" -TEXT_RED_500_COLOR = ["rgba(239, 68, 68, 1)", "rgb(239, 68, 68)"] +TEXT_RED_500_COLOR_v3 = ["rgba(239, 68, 68, 1)", "rgb(239, 68, 68)"] +TEXT_RED_500_COLOR_v4 = ["oklch(0.637 0.237 25.331)"] def TailwindApp( - tailwind_disabled: bool = False, + tailwind_version: int = 0, paragraph_text: str = PARAGRAPH_TEXT, paragraph_class_name: str = PARAGRAPH_CLASS_NAME, ): """App with tailwind optionally disabled. Args: - tailwind_disabled: Whether tailwind is disabled for the app. + tailwind_version: Tailwind version to use. If 0, tailwind is disabled. paragraph_text: Text for the paragraph. paragraph_class_name: Tailwind class_name for the paragraph. """ @@ -47,49 +48,59 @@ def index(): stylesheet.write_text(".external { color: rgba(0, 0, 255, 0.5) }") app = rx.App(style={"font_family": "monospace"}, stylesheets=[stylesheet.name]) app.add_page(index) - if tailwind_disabled: + if not tailwind_version: config = rx.config.get_config() config.tailwind = None + config.plugins = [] + elif tailwind_version == 3: + config = rx.config.get_config() + config.plugins = [rx.plugins.TailwindV3Plugin()] + elif tailwind_version == 4: + config = rx.config.get_config() + config.plugins = [rx.plugins.TailwindV4Plugin()] -@pytest.fixture(params=[False, True], ids=["tailwind_enabled", "tailwind_disabled"]) -def tailwind_disabled(request) -> bool: - """Tailwind disabled fixture. +@pytest.fixture( + params=[0, 3, 4], ids=["tailwind_disabled", "tailwind_v3", "tailwind_v4"] +) +def tailwind_version(request) -> int: + """Tailwind version fixture. Args: request: pytest request fixture. Returns: - True if tailwind is disabled, False otherwise. + Tailwind version to use. 0 for disabled, 3 for v3, 4 for v4. """ return request.param @pytest.fixture() -def tailwind_app(tmp_path, tailwind_disabled) -> Generator[AppHarness, None, None]: +def tailwind_app(tmp_path, tailwind_version) -> Generator[AppHarness, None, None]: """Start TailwindApp app at tmp_path via AppHarness with tailwind disabled via config. Args: tmp_path: pytest tmp_path fixture - tailwind_disabled: Whether tailwind is disabled for the app. + tailwind_version: Whether tailwind is disabled for the app. Yields: running AppHarness instance """ with AppHarness.create( root=tmp_path, - app_source=functools.partial(TailwindApp, tailwind_disabled=tailwind_disabled), - app_name="tailwind_disabled_app" if tailwind_disabled else "tailwind_app", + app_source=functools.partial(TailwindApp, tailwind_version=tailwind_version), + app_name="tailwind_" + + ("disabled" if tailwind_version == 0 else str(tailwind_version)), ) as harness: yield harness -def test_tailwind_app(tailwind_app: AppHarness, tailwind_disabled: bool): +def test_tailwind_app(tailwind_app: AppHarness, tailwind_version: bool): """Test that the app can compile without tailwind. Args: tailwind_app: AppHarness instance. - tailwind_disabled: Whether tailwind is disabled for the app. + tailwind_version: Tailwind version to use. If 0, tailwind is disabled. """ assert tailwind_app.app_instance is not None assert tailwind_app.backend is not None @@ -108,12 +119,15 @@ def test_tailwind_app(tailwind_app: AppHarness, tailwind_disabled: bool): for p in paragraphs: assert tailwind_app.poll_for_content(p, exp_not_equal="") == PARAGRAPH_TEXT assert p.value_of_css_property("font-family") == "monospace" - if tailwind_disabled: + if not tailwind_version: # expect default color, not "text-red-500" from tailwind utility class - assert p.value_of_css_property("color") not in TEXT_RED_500_COLOR - else: + assert p.value_of_css_property("color") not in TEXT_RED_500_COLOR_v3 + elif tailwind_version == 3: + # expect "text-red-500" from tailwind utility class + assert p.value_of_css_property("color") in TEXT_RED_500_COLOR_v3 + elif tailwind_version == 4: # expect "text-red-500" from tailwind utility class - assert p.value_of_css_property("color") in TEXT_RED_500_COLOR + assert p.value_of_css_property("color") in TEXT_RED_500_COLOR_v4 # Assert external stylesheet is applying rules external = driver.find_elements(By.CLASS_NAME, "external") diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index ff885b1d39d..26af944bfed 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -155,7 +155,7 @@ def test_compile_stylesheets(tmp_path: Path, mocker: MockerFixture): / "styles" / (PageNames.STYLESHEET_ROOT + ".css") ), - "@import url('./tailwind.css'); \n" + "@import url('@radix-ui/themes/styles.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('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css'); \n" @@ -215,7 +215,7 @@ def test_compile_stylesheets_scss_sass(tmp_path: Path, mocker: MockerFixture): / "styles" / (PageNames.STYLESHEET_ROOT + ".css") ), - "@import url('./tailwind.css'); \n" + "@import url('@radix-ui/themes/styles.css'); \n" "@import url('./style.css'); \n" f"@import url('./{Path('preprocess') / Path('styles_a.css')!s}'); \n" f"@import url('./{Path('preprocess') / Path('styles_b.css')!s}'); \n", @@ -233,7 +233,7 @@ def test_compile_stylesheets_scss_sass(tmp_path: Path, mocker: MockerFixture): / "styles" / (PageNames.STYLESHEET_ROOT + ".css") ), - "@import url('./tailwind.css'); \n" + "@import url('@radix-ui/themes/styles.css'); \n" "@import url('./style.css'); \n" f"@import url('./{Path('preprocess') / Path('styles_a.css')!s}'); \n" f"@import url('./{Path('preprocess') / Path('styles_b.css')!s}'); \n", @@ -267,6 +267,7 @@ def test_compile_stylesheets_exclude_tailwind(tmp_path, mocker: MockerFixture): mock = mocker.Mock() mocker.patch.object(mock, "tailwind", None) + mocker.patch.object(mock, "plugins", []) mocker.patch("reflex.compiler.compiler.get_config", return_value=mock) (assets_dir / "style.css").touch() @@ -278,7 +279,7 @@ def test_compile_stylesheets_exclude_tailwind(tmp_path, mocker: MockerFixture): assert compiler.compile_root_stylesheet(stylesheets) == ( str(Path(".web") / "styles" / (PageNames.STYLESHEET_ROOT + ".css")), - "@import url('./style.css'); \n", + "@import url('@radix-ui/themes/styles.css'); \n@import url('./style.css'); \n", ) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 44bfef9d43c..da8b5ee7508 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1333,6 +1333,17 @@ def compilable_app(tmp_path) -> Generator[tuple[App, Path], None, None]: web_dir = app_path / ".web" web_dir.mkdir(parents=True) (web_dir / constants.PackageJson.PATH).touch() + (web_dir / constants.Dirs.POSTCSS_JS).touch() + (web_dir / constants.Dirs.POSTCSS_JS).write_text( + """ +module.exports = { + plugins: { + "postcss-import": {}, + autoprefixer: {}, + }, +}; +""", + ) app = App(theme=None) app._get_frontend_packages = unittest.mock.Mock() with chdir(app_path): @@ -1355,8 +1366,8 @@ def test_app_wrap_compile_theme( """ conf = rx.Config(app_name="testing", react_strict_mode=react_strict_mode) mocker.patch("reflex.config._get_config", return_value=conf) - app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) app.theme = rx.theme(accent_color="plum") app._compile() app_js_contents = (web_dir / "pages" / "_app.js").read_text()