Coverage for pyeditorjs/blocks.py: 74%

1 import abc

2 import typing as t

3 from dataclasses import dataclass

4

5 import bleach

6

7 from .exceptions import EditorJsParseError

8

9 __all__ = [

10 "block",

11 "BLOCKS_MAP",

12 "EditorJsBlock",

13]

14

15

16 def _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 )

22

23

24 BLOCKS_MAP: t.Dict[str, t.Type["EditorJsBlock"]] = {

25 # 'header': HeaderBlock,

26 # 'paragraph': ParagraphBlock,

27 # 'list': ListBlock,

28 # 'delimiter': DelimiterBlock,

29 # 'image': ImageBlock,

30}

31

32

33 def block(_type: str):

34 def wrapper(cls: t.Type["EditorJsBlock"]):

35 BLOCKS_MAP[_type] = cls

36 return cls

37

38 return wrapper

39

40

41@dataclass

42 class EditorJsBlock(abc.ABC):

43 """

44 A generic parsed Editor.js block

45 """

46

47 _data: dict

48 """The raw JSON data of the entire block"""

49

50 @property

51 def id(self) -> t.Optional[str]:

52 """

53 Returns ID of the block, generated client-side.

54 """

55

56 return self._data.get("id", None)

57

58 @property

59 def type(self) -> t.Optional[str]:

60 """

61 Returns the type of the block.

62 """

63

64 return self._data.get("type", None)

65

66 @property

67 def data(self) -> dict:

68 """

69 Returns the actual block data.

70 """

71

72 return self._data.get("data", {})

73

74 @abc.abstractmethod

75 def html(self, sanitize: bool = False) -> str:

76 """

77 Returns the HTML representation of the block.

78

79 ### Parameters:

80 - `sanitize` - if `True`, then the block's text/contents will be sanitized.

81 """

82

83 raise NotImplementedError()

84

85

86@block("header")

87 class HeaderBlock(EditorJsBlock):

88 VALID_HEADER_LEVEL_RANGE = range(1, 7)

89 """Valid range for header levels. Default is `range(1, 7)` - so, `0` - `6`."""

90

91 @property

92 def text(self) -> str:

93 """

94 Returns the header's text.

95 """

96

97 return self.data.get("text", "")

98

99 @property

100 def level(self) -> int:

101 """

102 Returns the header's level (`0` - `6`).

103 """

104

105 _level = self.data.get("level", 1)

106

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

109

110 return _level

111

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}>'

117

118

119@block("paragraph")

120 class ParagraphBlock(EditorJsBlock):

121 @property

122 def text(self) -> str:

123 """

124 The text content of the paragraph.

125 """

126

127 return self.data.get("text", "")

128

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

131

132

133@block("list")

134 class ListBlock(EditorJsBlock):

135 VALID_STYLES = ("unordered", "ordered")

136 """Valid list order styles."""

137

138 @property

139 def style(self) -> t.Optional[str]:

140 """

141 The style of the list. Can be `ordered` or `unordered`.

142 """

143

144 return self.data.get("style", None)

145

146 @property

147 def items(self) -> t.List[str]:

148 """

149 Returns the list's items, in raw format.

150 """

151

152 return self.data.get("items", [])

153

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

157

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)

163

164 return rf'<{_type} class="cdx-block cdx-list cdx-list--{self.style}">{_items_html}</{_type}>'

165

166

167@block("delimiter")

168 class DelimiterBlock(EditorJsBlock):

169 def html(self, sanitize: bool = False) -> str:

170 return r'<div class="cdx-block ce-delimiter"></div>'

171

172

173@block("image")

174 class ImageBlock(EditorJsBlock):

175 @property

176 def file_url(self) -> str:

177 """

178 URL of the image file.

179 """

180

181 return self.data.get("file", {}).get("url", "")

182

183 @property

184 def caption(self) -> str:

185 """

186 The image's caption.

187 """

188

189 return self.data.get("caption", "")

190

191 @property

192 def with_border(self) -> bool:

193 """

194 Whether the image has a border.

195 """

196

197 return self.data.get("withBorder", False)

198

199 @property

200 def stretched(self) -> bool:

201 """

202 Whether the image is stretched.

203 """

204

205 return self.data.get("stretched", False)

206

207 @property

208 def with_background(self) -> bool:

209 """

210 Whether the image has a background.

211 """

212

213 return self.data.get("withBackground", False)

214

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

220

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 ]

231

232 return "".join(parts)

233

234

235@block("quote")

236 class 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 """

250

251

252@block("table")

253 class 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

258

259 html_table = '<table class="tc-table">'

260

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

269

270 html_table += "</table>"

271 return html_table

272

273

274@block("code")

275 class 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 """

283

284

285@block("warning")

286 class WarningBlock(EditorJsBlock):

287 def html(self, sanitize: bool = False) -> str:

288 title = self.data.get("title", "")

289 message = self.data.get("message", "")

290

291 if sanitize:

292 title = _sanitize(title)

293 message = _sanitize(message)

294

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

301

302

303@block("raw")

304 class 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