diff --git a/reflex/.templates/web/components/shiki/code.js b/reflex/.templates/web/components/shiki/code.js index c655c3a60..22b3693b4 100644 --- a/reflex/.templates/web/components/shiki/code.js +++ b/reflex/.templates/web/components/shiki/code.js @@ -1,26 +1,31 @@ import { useEffect, useState } from "react" import { codeToHtml} from "shiki" -export function Code ({code, theme, language, transformers, ...divProps}) { +/** + * Code component that uses Shiki to convert code to HTML and render it. + * + * @param code - The code to be highlighted. + * @param theme - The theme to be used for highlighting. + * @param language - The language of the code. + * @param transformers - The transformers to be applied to the code. + * @param decorations - The decorations to be applied to the code. + * @param divProps - Additional properties to be passed to the div element. + * @returns The rendered code block. + */ +export function Code ({code, theme, language, transformers, decorations, ...divProps}) { const [codeResult, setCodeResult] = useState("") useEffect(() => { async function fetchCode() { - let final_code; - - if (Array.isArray(code)) { - final_code = code[0]; - } else { - final_code = code; - } - const result = await codeToHtml(final_code, { + const result = await codeToHtml(code, { lang: language, theme, - transformers + transformers, + decorations }); setCodeResult(result); } fetchCode(); - }, [code, language, theme, transformers] + }, [code, language, theme, transformers, decorations] ) return ( diff --git a/reflex/components/datadisplay/shiki_code_block.py b/reflex/components/datadisplay/shiki_code_block.py index 6ce6916c6..07f09c6f6 100644 --- a/reflex/components/datadisplay/shiki_code_block.py +++ b/reflex/components/datadisplay/shiki_code_block.py @@ -12,6 +12,7 @@ from reflex.components.core.colors import color from reflex.components.core.cond import color_mode_cond from reflex.components.el.elements.forms import Button from reflex.components.lucide.icon import Icon +from reflex.components.props import NoExtrasAllowedProps from reflex.components.radix.themes.layout.box import Box from reflex.event import call_script, set_clipboard from reflex.style import Style @@ -253,6 +254,7 @@ LiteralCodeLanguage = Literal[ "pascal", "perl", "php", + "plain", "plsql", "po", "postcss", @@ -369,10 +371,11 @@ LiteralCodeTheme = Literal[ "nord", "one-dark-pro", "one-light", - "plain", "plastic", "poimandres", "red", + # rose-pine themes dont work with the current version of shikijs transformers + # https://github.com/shikijs/shiki/issues/730 "rose-pine", "rose-pine-dawn", "rose-pine-moon", @@ -390,6 +393,23 @@ LiteralCodeTheme = Literal[ ] +class Position(NoExtrasAllowedProps): + """Position of the decoration.""" + + line: int + character: int + + +class ShikiDecorations(NoExtrasAllowedProps): + """Decorations for the code block.""" + + start: Union[int, Position] + end: Union[int, Position] + tag_name: str = "span" + properties: dict[str, Any] = {} + always_wrap: bool = False + + class ShikiBaseTransformers(Base): """Base for creating transformers.""" @@ -537,6 +557,9 @@ class ShikiCodeBlock(Component): [] ) + # The decorations to use for the syntax highlighter. + decorations: Var[list[ShikiDecorations]] = Var.create([]) + @classmethod def create( cls, @@ -555,6 +578,7 @@ class ShikiCodeBlock(Component): # Separate props for the code block and the wrapper code_block_props = {} code_wrapper_props = {} + decorations = props.pop("decorations", []) class_props = cls.get_props() @@ -564,6 +588,15 @@ class ShikiCodeBlock(Component): value ) + # cast decorations into ShikiDecorations. + decorations = [ + ShikiDecorations(**decoration) + if not isinstance(decoration, ShikiDecorations) + else decoration + for decoration in decorations + ] + code_block_props["decorations"] = decorations + code_block_props["code"] = children[0] code_block = super().create(**code_block_props) @@ -676,10 +709,10 @@ class ShikiHighLevelCodeBlock(ShikiCodeBlock): show_line_numbers: Var[bool] # Whether a copy button should appear. - can_copy: Var[bool] = Var.create(False) + can_copy: bool = False # copy_button: A custom copy button to override the default one. - copy_button: Var[Optional[Union[Component, bool]]] = Var.create(None) + copy_button: Optional[Union[Component, bool]] = None @classmethod def create( diff --git a/reflex/components/datadisplay/shiki_code_block.pyi b/reflex/components/datadisplay/shiki_code_block.pyi index bcf2719e9..e2475aed5 100644 --- a/reflex/components/datadisplay/shiki_code_block.pyi +++ b/reflex/components/datadisplay/shiki_code_block.pyi @@ -7,6 +7,7 @@ from typing import Any, Dict, Literal, Optional, Union, overload from reflex.base import Base from reflex.components.component import Component, ComponentNamespace +from reflex.components.props import NoExtrasAllowedProps from reflex.event import EventType from reflex.style import Style from reflex.vars.base import Var @@ -192,6 +193,7 @@ LiteralCodeLanguage = Literal[ "pascal", "perl", "php", + "plain", "plsql", "po", "postcss", @@ -308,7 +310,6 @@ LiteralCodeTheme = Literal[ "nord", "one-dark-pro", "one-light", - "plain", "plastic", "poimandres", "red", @@ -328,6 +329,17 @@ LiteralCodeTheme = Literal[ "vitesse-light", ] +class Position(NoExtrasAllowedProps): + line: int + character: int + +class ShikiDecorations(NoExtrasAllowedProps): + start: Union[int, Position] + end: Union[int, Position] + tag_name: str + properties: dict[str, Any] + always_wrap: bool + class ShikiBaseTransformers(Base): library: str fns: list[FunctionStringVar] @@ -479,6 +491,7 @@ class ShikiCodeBlock(Component): "pascal", "perl", "php", + "plain", "plsql", "po", "postcss", @@ -694,6 +707,7 @@ class ShikiCodeBlock(Component): "pascal", "perl", "php", + "plain", "plsql", "po", "postcss", @@ -815,7 +829,6 @@ class ShikiCodeBlock(Component): "nord", "one-dark-pro", "one-light", - "plain", "plastic", "poimandres", "red", @@ -870,7 +883,6 @@ class ShikiCodeBlock(Component): "nord", "one-dark-pro", "one-light", - "plain", "plastic", "poimandres", "red", @@ -906,6 +918,9 @@ class ShikiCodeBlock(Component): list[Union[ShikiBaseTransformers, dict[str, Any]]], ] ] = None, + decorations: Optional[ + Union[Var[list[ShikiDecorations]], list[ShikiDecorations]] + ] = None, style: Optional[Style] = None, key: Optional[Any] = None, id: Optional[Any] = None, @@ -938,6 +953,7 @@ class ShikiCodeBlock(Component): themes: The set of themes to use for different modes. code: The code to display. transformers: The transformers to use for the syntax highlighter. + decorations: The decorations to use for the syntax highlighter. style: The style of the component. key: A unique key for the component. id: The id for the component. @@ -965,10 +981,8 @@ class ShikiHighLevelCodeBlock(ShikiCodeBlock): *children, use_transformers: Optional[Union[Var[bool], bool]] = None, show_line_numbers: Optional[Union[Var[bool], bool]] = None, - can_copy: Optional[Union[Var[bool], bool]] = None, - copy_button: Optional[ - Union[Component, Var[Optional[Union[Component, bool]]], bool] - ] = None, + can_copy: Optional[bool] = None, + copy_button: Optional[Union[Component, bool]] = None, language: Optional[ Union[ Literal[ @@ -1104,6 +1118,7 @@ class ShikiHighLevelCodeBlock(ShikiCodeBlock): "pascal", "perl", "php", + "plain", "plsql", "po", "postcss", @@ -1319,6 +1334,7 @@ class ShikiHighLevelCodeBlock(ShikiCodeBlock): "pascal", "perl", "php", + "plain", "plsql", "po", "postcss", @@ -1440,7 +1456,6 @@ class ShikiHighLevelCodeBlock(ShikiCodeBlock): "nord", "one-dark-pro", "one-light", - "plain", "plastic", "poimandres", "red", @@ -1495,7 +1510,6 @@ class ShikiHighLevelCodeBlock(ShikiCodeBlock): "nord", "one-dark-pro", "one-light", - "plain", "plastic", "poimandres", "red", @@ -1531,6 +1545,9 @@ class ShikiHighLevelCodeBlock(ShikiCodeBlock): list[Union[ShikiBaseTransformers, dict[str, Any]]], ] ] = None, + decorations: Optional[ + Union[Var[list[ShikiDecorations]], list[ShikiDecorations]] + ] = None, style: Optional[Style] = None, key: Optional[Any] = None, id: Optional[Any] = None, @@ -1567,6 +1584,7 @@ class ShikiHighLevelCodeBlock(ShikiCodeBlock): themes: The set of themes to use for different modes. code: The code to display. transformers: The transformers to use for the syntax highlighter. + decorations: The decorations to use for the syntax highlighter. style: The style of the component. key: A unique key for the component. id: The id for the component. @@ -1593,10 +1611,8 @@ class CodeblockNamespace(ComponentNamespace): *children, use_transformers: Optional[Union[Var[bool], bool]] = None, show_line_numbers: Optional[Union[Var[bool], bool]] = None, - can_copy: Optional[Union[Var[bool], bool]] = None, - copy_button: Optional[ - Union[Component, Var[Optional[Union[Component, bool]]], bool] - ] = None, + can_copy: Optional[bool] = None, + copy_button: Optional[Union[Component, bool]] = None, language: Optional[ Union[ Literal[ @@ -1732,6 +1748,7 @@ class CodeblockNamespace(ComponentNamespace): "pascal", "perl", "php", + "plain", "plsql", "po", "postcss", @@ -1947,6 +1964,7 @@ class CodeblockNamespace(ComponentNamespace): "pascal", "perl", "php", + "plain", "plsql", "po", "postcss", @@ -2068,7 +2086,6 @@ class CodeblockNamespace(ComponentNamespace): "nord", "one-dark-pro", "one-light", - "plain", "plastic", "poimandres", "red", @@ -2123,7 +2140,6 @@ class CodeblockNamespace(ComponentNamespace): "nord", "one-dark-pro", "one-light", - "plain", "plastic", "poimandres", "red", @@ -2159,6 +2175,9 @@ class CodeblockNamespace(ComponentNamespace): list[Union[ShikiBaseTransformers, dict[str, Any]]], ] ] = None, + decorations: Optional[ + Union[Var[list[ShikiDecorations]], list[ShikiDecorations]] + ] = None, style: Optional[Style] = None, key: Optional[Any] = None, id: Optional[Any] = None, @@ -2195,6 +2214,7 @@ class CodeblockNamespace(ComponentNamespace): themes: The set of themes to use for different modes. code: The code to display. transformers: The transformers to use for the syntax highlighter. + decorations: The decorations to use for the syntax highlighter. style: The style of the component. key: A unique key for the component. id: The id for the component. diff --git a/reflex/components/props.py b/reflex/components/props.py index 92dbe8144..adce134fc 100644 --- a/reflex/components/props.py +++ b/reflex/components/props.py @@ -2,8 +2,11 @@ from __future__ import annotations +from pydantic import ValidationError + from reflex.base import Base from reflex.utils import format +from reflex.utils.exceptions import InvalidPropValueError from reflex.vars.object import LiteralObjectVar @@ -40,3 +43,34 @@ class PropsBase(Base): format.to_camel_case(key): value for key, value in super().dict(*args, **kwargs).items() } + + +class NoExtrasAllowedProps(Base): + """A class that holds props to be passed or applied to a component with no extra props allowed.""" + + def __init__(self, component_name=None, **kwargs): + """Initialize the props. + + Args: + component_name: The custom name of the component. + kwargs: Kwargs to initialize the props. + + Raises: + InvalidPropValueError: If invalid props are passed on instantiation. + """ + component_name = component_name or type(self).__name__ + try: + super().__init__(**kwargs) + except ValidationError as e: + invalid_fields = ", ".join([error["loc"][0] for error in e.errors()]) # type: ignore + supported_props_str = ", ".join(f'"{field}"' for field in self.get_fields()) + raise InvalidPropValueError( + f"Invalid prop(s) {invalid_fields} for {component_name!r}. Supported props are {supported_props_str}" + ) from None + + class Config: + """Pydantic config.""" + + arbitrary_types_allowed = True + use_enum_values = True + extra = "forbid" diff --git a/reflex/components/sonner/toast.py b/reflex/components/sonner/toast.py index 65f1157d3..175c68f63 100644 --- a/reflex/components/sonner/toast.py +++ b/reflex/components/sonner/toast.py @@ -4,12 +4,10 @@ from __future__ import annotations from typing import Any, ClassVar, Literal, Optional, Union -from pydantic import ValidationError - from reflex.base import Base from reflex.components.component import Component, ComponentNamespace from reflex.components.lucide.icon import Icon -from reflex.components.props import PropsBase +from reflex.components.props import NoExtrasAllowedProps, PropsBase from reflex.event import ( EventSpec, call_script, @@ -72,7 +70,7 @@ def _toast_callback_signature(toast: Var) -> list[Var]: ] -class ToastProps(PropsBase): +class ToastProps(PropsBase, NoExtrasAllowedProps): """Props for the toast component.""" # Toast's title, renders above the description. @@ -132,24 +130,6 @@ class ToastProps(PropsBase): # Function that gets called when the toast disappears automatically after it's timeout (duration` prop). on_auto_close: Optional[Any] - def __init__(self, **kwargs): - """Initialize the props. - - Args: - kwargs: Kwargs to initialize the props. - - Raises: - ValueError: If invalid props are passed on instantiation. - """ - try: - super().__init__(**kwargs) - except ValidationError as e: - invalid_fields = ", ".join([error["loc"][0] for error in e.errors()]) # type: ignore - supported_props_str = ", ".join(f'"{field}"' for field in self.get_fields()) - raise ValueError( - f"Invalid prop(s) {invalid_fields} for rx.toast. Supported props are {supported_props_str}" - ) from None - def dict(self, *args, **kwargs) -> dict[str, Any]: """Convert the object to a dictionary. @@ -181,13 +161,6 @@ class ToastProps(PropsBase): ) return d - class Config: - """Pydantic config.""" - - arbitrary_types_allowed = True - use_enum_values = True - extra = "forbid" - class Toaster(Component): """A Toaster Component for displaying toast notifications.""" @@ -281,7 +254,7 @@ class Toaster(Component): if message == "" and ("title" not in props or "description" not in props): raise ValueError("Toast message or title or description must be provided.") if props: - args = LiteralVar.create(ToastProps(**props)) + args = LiteralVar.create(ToastProps(component_name="rx.toast", **props)) # type: ignore toast = f"{toast_command}(`{message}`, {str(args)})" else: toast = f"{toast_command}(`{message}`)" diff --git a/reflex/components/sonner/toast.pyi b/reflex/components/sonner/toast.pyi index 976f9f310..10168257e 100644 --- a/reflex/components/sonner/toast.pyi +++ b/reflex/components/sonner/toast.pyi @@ -8,7 +8,7 @@ from typing import Any, ClassVar, Dict, Literal, Optional, Union, overload from reflex.base import Base from reflex.components.component import Component, ComponentNamespace from reflex.components.lucide.icon import Icon -from reflex.components.props import PropsBase +from reflex.components.props import NoExtrasAllowedProps, PropsBase from reflex.event import EventSpec, EventType from reflex.style import Style from reflex.utils.serializers import serializer @@ -31,7 +31,7 @@ class ToastAction(Base): @serializer def serialize_action(action: ToastAction) -> dict: ... -class ToastProps(PropsBase): +class ToastProps(PropsBase, NoExtrasAllowedProps): title: Optional[Union[str, Var]] description: Optional[Union[str, Var]] close_button: Optional[bool] @@ -52,11 +52,6 @@ class ToastProps(PropsBase): def dict(self, *args, **kwargs) -> dict[str, Any]: ... - class Config: - arbitrary_types_allowed = True - use_enum_values = True - extra = "forbid" - class Toaster(Component): is_used: ClassVar[bool] = False diff --git a/reflex/utils/exceptions.py b/reflex/utils/exceptions.py index f16338513..6d4a0bbc7 100644 --- a/reflex/utils/exceptions.py +++ b/reflex/utils/exceptions.py @@ -143,3 +143,7 @@ class EnvironmentVarValueError(ReflexError, ValueError): class DynamicComponentInvalidSignature(ReflexError, TypeError): """Raised when a dynamic component has an invalid signature.""" + + +class InvalidPropValueError(ReflexError): + """Raised when a prop value is invalid.""" diff --git a/tests/units/components/test_props.py b/tests/units/components/test_props.py new file mode 100644 index 000000000..8ab07f135 --- /dev/null +++ b/tests/units/components/test_props.py @@ -0,0 +1,63 @@ +import pytest + +from reflex.components.props import NoExtrasAllowedProps +from reflex.utils.exceptions import InvalidPropValueError + +try: + from pydantic.v1 import ValidationError +except ModuleNotFoundError: + from pydantic import ValidationError + + +class PropA(NoExtrasAllowedProps): + """Base prop class.""" + + foo: str + bar: str + + +class PropB(NoExtrasAllowedProps): + """Prop class with nested props.""" + + foobar: str + foobaz: PropA + + +@pytest.mark.parametrize( + "props_class, kwargs, should_raise", + [ + (PropA, {"foo": "value", "bar": "another_value"}, False), + (PropA, {"fooz": "value", "bar": "another_value"}, True), + ( + PropB, + { + "foobaz": {"foo": "value", "bar": "another_value"}, + "foobar": "foo_bar_value", + }, + False, + ), + ( + PropB, + { + "fooba": {"foo": "value", "bar": "another_value"}, + "foobar": "foo_bar_value", + }, + True, + ), + ( + PropB, + { + "foobaz": {"foobar": "value", "bar": "another_value"}, + "foobar": "foo_bar_value", + }, + True, + ), + ], +) +def test_no_extras_allowed_props(props_class, kwargs, should_raise): + if should_raise: + with pytest.raises((ValidationError, InvalidPropValueError)): + props_class(**kwargs) + else: + props_instance = props_class(**kwargs) + assert isinstance(props_instance, props_class)