Coverage for src/lexigram/admin/schema/text_area.py: 93%
85 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1from __future__ import annotations
3from dataclasses import dataclass
4import html as _html
5from typing import Any
7from lexigram.admin.schema.text import TextField
8from lexigram.security.sanitization.html import sanitize_html
9from lexigram.ui import Element, MarkdownEditor, RichEditor, TextArea, raw
12def _sanitize_rich_text(value: str) -> str:
13 """Sanitize rich-text HTML, escaping verbatim when nh3 is unavailable."""
14 try:
15 return sanitize_html(value)
16 except ImportError:
17 return _html.escape(value)
20@dataclass(frozen=True, kw_only=True)
21class TextAreaField(TextField):
22 """A multi-line text input field.
24 Renders a ``<textarea>`` form control. Column display truncates
25 long content at 200 characters.
26 """
28 rows: int = 5
30 def render_form(
31 self, value: str | None, *, errors: list[str] | None = None
32 ) -> Element:
33 kwargs: dict[str, Any] = {
34 "name": self.name,
35 "value": value if value is not None else "",
36 "rows": self.rows,
37 }
38 if self.placeholder is not None:
39 kwargs["placeholder"] = self.placeholder
40 if self.label is not None:
41 kwargs["label"] = self.label
42 if errors:
43 kwargs["error"] = errors[0]
44 if self.readonly:
45 kwargs["disabled"] = True
46 return TextArea(**kwargs).render()
48 def render_column(self, record: Any, value: str | None) -> Element:
49 if value is None:
50 return Element("span", "\u2014", class_="text-muted")
51 if len(value) > 200:
52 return Element("span", value[:200] + "...")
53 return Element("span", value)
56@dataclass(frozen=True, kw_only=True)
57class MarkdownField(TextAreaField):
58 """A markdown editor field with optional preview toggle.
60 Renders a ``MarkdownEditor`` form control with Alpine.js-powered
61 preview mode switching. Column display strips markdown syntax
62 for plain-text preview.
63 """
65 preview: bool = True
66 min_height: int = 300
68 def render_form(
69 self, value: str | None, *, errors: list[str] | None = None
70 ) -> Element:
71 kwargs: dict[str, Any] = {
72 "name": self.name,
73 "value": value if value is not None else "",
74 "rows": self.rows,
75 "preview": self.preview,
76 "min_height": self.min_height,
77 }
78 if self.placeholder is not None:
79 kwargs["placeholder"] = self.placeholder
80 if self.label is not None:
81 kwargs["label"] = self.label
82 if errors:
83 kwargs["error"] = errors[0]
84 if self.readonly:
85 kwargs["disabled"] = True
86 return MarkdownEditor(**kwargs).render()
88 def render_column(self, record: Any, value: str | None) -> Element:
89 if value is None:
90 return Element("span", "\u2014", class_="text-muted")
91 text = value
92 text = text.replace("**", "").replace("__", "")
93 text = text.replace("*", "").replace("_", "")
94 text = text.replace("```", "").replace("`", "")
95 text = text.replace("#", "")
96 text = text.replace("> ", "")
97 text = text.replace("[", "").replace("]", "")
98 text = text.replace("(", "").replace(")", "")
99 return Element("span", text.strip())
102@dataclass(frozen=True, kw_only=True)
103class RichTextField(TextAreaField):
104 """A WYSIWYG rich-text field.
106 Renders a ``RichEditor`` form control backed by Trix.js.
107 Column display renders the HTML value as trusted (unescaped)
108 content.
110 Use ``render_assets()`` to include the required Trix.js CSS and
111 JS when this field appears on a page.
112 """
114 toolbar: str | None = None
115 min_height: int = 300
117 _trix_css_url: str = "https://unpkg.com/trix@2.0.8/dist/trix.css"
118 _trix_js_url: str = "https://unpkg.com/trix@2.0.8/dist/trix.umd.min.js"
120 @classmethod
121 def render_assets(cls) -> Element:
122 """Render the Trix.js CSS and ``<script>`` tags.
124 Include the result in ``<head>`` once per page to enable
125 RichEditor instances.
126 """
127 return Element(
128 "div",
129 Element(
130 "link",
131 rel="stylesheet",
132 href=cls._trix_css_url,
133 ),
134 Element(
135 "script",
136 src=cls._trix_js_url,
137 defer="",
138 ),
139 )
141 def render_form(
142 self, value: str | None, *, errors: list[str] | None = None
143 ) -> Element:
144 kwargs: dict[str, Any] = {
145 "name": self.name,
146 "value": value if value is not None else "",
147 "min_height": self.min_height,
148 }
149 if self.placeholder is not None:
150 kwargs["placeholder"] = self.placeholder
151 if self.label is not None:
152 kwargs["label"] = self.label
153 if self.toolbar is not None:
154 kwargs["toolbar"] = self.toolbar
155 if errors:
156 kwargs["error"] = errors[0]
157 if self.readonly:
158 kwargs["disabled"] = True
159 return RichEditor(**kwargs).render()
161 def render_column(self, record: Any, value: str | None) -> Element:
162 if value is None:
163 return Element("span", "\u2014", class_="text-muted")
164 return Element("div", raw(_sanitize_rich_text(value)))