Coverage for pyeditorjs/blocks.py: 74%
146 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-10-31 13:29 +0100
« prev ^ index » next coverage.py v7.6.4, created at 2024-10-31 13:29 +0100
1import abc
2import typing as t
3from dataclasses import dataclass
5import bleach
7from .exceptions import EditorJsParseError
9__all__ = [
10 "block",
11 "BLOCKS_MAP",
12 "EditorJsBlock",
13]
16def _sanitize(html: str) -> str:
17 return bleach.clean(
18 html,
19 tags=["b", "i", "u", "a", "mark", "code"],
20 attributes=["class", "data-placeholder", "href"],
21 )
24BLOCKS_MAP: t.Dict[str, t.Type["EditorJsBlock"]] = {
25 # 'header': HeaderBlock,
26 # 'paragraph': ParagraphBlock,
27 # 'list': ListBlock,
28 # 'delimiter': DelimiterBlock,
29 # 'image': ImageBlock,
30}
33def block(_type: str):
34 def wrapper(cls: t.Type["EditorJsBlock"]):
35 BLOCKS_MAP[_type] = cls
36 return cls
38 return wrapper
41@dataclass
42class EditorJsBlock(abc.ABC):
43 """
44 A generic parsed Editor.js block
45 """
47 _data: dict
48 """The raw JSON data of the entire block"""
50 @property
51 def id(self) -> t.Optional[str]:
52 """
53 Returns ID of the block, generated client-side.
54 """
56 return self._data.get("id", None)
58 @property
59 def type(self) -> t.Optional[str]:
60 """
61 Returns the type of the block.
62 """
64 return self._data.get("type", None)
66 @property
67 def data(self) -> dict:
68 """
69 Returns the actual block data.
70 """
72 return self._data.get("data", {})
74 @abc.abstractmethod
75 def html(self, sanitize: bool = False) -> str:
76 """
77 Returns the HTML representation of the block.
79 ### Parameters:
80 - `sanitize` - if `True`, then the block's text/contents will be sanitized.
81 """
83 raise NotImplementedError()
86@block("header")
87class HeaderBlock(EditorJsBlock):
88 VALID_HEADER_LEVEL_RANGE = range(1, 7)
89 """Valid range for header levels. Default is `range(1, 7)` - so, `0` - `6`."""
91 @property
92 def text(self) -> str:
93 """
94 Returns the header's text.
95 """
97 return self.data.get("text", "")
99 @property
100 def level(self) -> int:
101 """
102 Returns the header's level (`0` - `6`).
103 """
105 _level = self.data.get("level", 1)
107 if not isinstance(_level, int) or _level not in self.VALID_HEADER_LEVEL_RANGE:
108 raise EditorJsParseError(f"`{_level}` is not a valid header level.")
110 return _level
112 def html(self, sanitize: bool = False) -> str:
113 text = self.text
114 if sanitize:
115 text = _sanitize(text)
116 return rf'<h{self.level} class="cdx-block ce-header">{text}</h{self.level}>'
119@block("paragraph")
120class ParagraphBlock(EditorJsBlock):
121 @property
122 def text(self) -> str:
123 """
124 The text content of the paragraph.
125 """
127 return self.data.get("text", "")
129 def html(self, sanitize: bool = False) -> str:
130 return rf'<p class="cdx-block ce-paragraph">{_sanitize(self.text) if sanitize else self.text}</p>'
133@block("list")
134class ListBlock(EditorJsBlock):
135 VALID_STYLES = ("unordered", "ordered")
136 """Valid list order styles."""
138 @property
139 def style(self) -> t.Optional[str]:
140 """
141 The style of the list. Can be `ordered` or `unordered`.
142 """
144 return self.data.get("style", None)
146 @property
147 def items(self) -> t.List[str]:
148 """
149 Returns the list's items, in raw format.
150 """
152 return self.data.get("items", [])
154 def html(self, sanitize: bool = False) -> str:
155 if self.style not in self.VALID_STYLES:
156 raise EditorJsParseError(f"`{self.style}` is not a valid list style.")
158 _items = [
159 f"<li>{_sanitize(item) if sanitize else item}</li>" for item in self.items
160 ]
161 _type = "ul" if self.style == "unordered" else "ol"
162 _items_html = "".join(_items)
164 return rf'<{_type} class="cdx-block cdx-list cdx-list--{self.style}">{_items_html}</{_type}>'
167@block("delimiter")
168class DelimiterBlock(EditorJsBlock):
169 def html(self, sanitize: bool = False) -> str:
170 return r'<div class="cdx-block ce-delimiter"></div>'
173@block("image")
174class ImageBlock(EditorJsBlock):
175 @property
176 def file_url(self) -> str:
177 """
178 URL of the image file.
179 """
181 return self.data.get("file", {}).get("url", "")
183 @property
184 def caption(self) -> str:
185 """
186 The image's caption.
187 """
189 return self.data.get("caption", "")
191 @property
192 def with_border(self) -> bool:
193 """
194 Whether the image has a border.
195 """
197 return self.data.get("withBorder", False)
199 @property
200 def stretched(self) -> bool:
201 """
202 Whether the image is stretched.
203 """
205 return self.data.get("stretched", False)
207 @property
208 def with_background(self) -> bool:
209 """
210 Whether the image has a background.
211 """
213 return self.data.get("withBackground", False)
215 def html(self, sanitize: bool = False) -> str:
216 if self.file_url.startswith("data:image/"):
217 _img = self.file_url
218 else:
219 _img = _sanitize(self.file_url) if sanitize else self.file_url
221 parts = [
222 rf'<div class="cdx-block image-tool image-tool--filled {"image-tool--stretched" if self.stretched else ""} {"image-tool--withBorder" if self.with_border else ""} {"image-tool--withBackground" if self.with_background else ""}">'
223 r'<div class="image-tool__image">',
224 r'<div class="image-tool__image-preloader"></div>',
225 rf'<img class="image-tool__image-picture" src="{_img}"/>',
226 r"</div>"
227 rf'<div class="image-tool__caption" data-placeholder="{_sanitize(self.caption) if sanitize else self.caption}"></div>'
228 r"</div>"
229 r"</div>",
230 ]
232 return "".join(parts)
235@block("quote")
236class QuoteBlock(EditorJsBlock):
237 def html(self, sanitize: bool = False) -> str:
238 quote = self.data.get("text", "")
239 caption = self.data.get("caption", "")
240 if sanitize:
241 quote = _sanitize(quote)
242 caption = _sanitize(caption)
243 _alignment = self.data.get("alignment", "left") # todo
244 return f"""
245 <blockquote class="cdx-block cdx-quote">
246 <div class="cdx-input cdx-quote__text">{quote}</div>
247 <cite class="cdx-input cdx-quote__caption">{caption}</cite>
248 </blockquote>
249 """
252@block("table")
253class TableBlock(EditorJsBlock):
254 def html(self, sanitize: bool = False) -> str:
255 content = self.data.get("content", [])
256 _stretched = self.data.get("stretched", False) # todo
257 _with_headings = self.data.get("withHeadings", False) # todo
259 html_table = '<table class="tc-table">'
261 # Add content rows
262 for row in content:
263 html_table += '<tr class="tc-row">'
264 for cell in row:
265 html_table += (
266 f'<td class="tc-cell">{_sanitize(cell) if sanitize else cell}</td>'
267 )
268 html_table += "</tr>"
270 html_table += "</table>"
271 return html_table
274@block("code")
275class CodeBlock(EditorJsBlock):
276 def html(self, sanitize: bool = False) -> str:
277 code = self.data.get("code", "")
278 if sanitize:
279 code = _sanitize(code)
280 return f"""
281 <code class="ce-code__textarea cdx-input" data-empty="false">{code}</code>
282 """
285@block("warning")
286class WarningBlock(EditorJsBlock):
287 def html(self, sanitize: bool = False) -> str:
288 title = self.data.get("title", "")
289 message = self.data.get("message", "")
291 if sanitize:
292 title = _sanitize(title)
293 message = _sanitize(message)
295 return f"""
296 <div class="cdx-block cdx-warning">
297 <div class="cdx-input cdx-warning__title">{title}</div>
298 <div class="cdx-input cdx-warning__message">{message}</div>
299 </div>
300 """
303@block("raw")
304class RawBlock(EditorJsBlock):
305 def html(self, sanitize: bool = False) -> str:
306 html = self.data.get("html", "")
307 if sanitize:
308 html = _sanitize(html)
309 return html