[ENG-3892]Shiki codeblock support decorations (#4234)

* Shiki codeblock support decorations

* add decorations to useEffect

* fix pyright

* validate decorations dict

* Fix exception message plus unit tests

* possible test fix

* fix pyright

* possible tests fix

* cast decorations before creating codeblock

* `plain` is not a valid theme

* pyi fix

* address PR comment
This commit is contained in:
Elijah Ahianyo 2024-10-31 09:33:49 +00:00 committed by GitHub
parent 84b0864e7e
commit a968231750
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 193 additions and 66 deletions

View File

@ -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 (

View File

@ -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(

View File

@ -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.

View File

@ -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"

View File

@ -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}`)"

View File

@ -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

View File

@ -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."""

View File

@ -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)