1"""
2Configuration objects for the tree's output renderers.
3
4``to_markdown``, ``to_text``/``to_annotated_text`` and ``serialize``/``encode`` each carried a long flat keyword list
5that was hard to read, hard to document, and let mutually exclusive options (markdown's ``strip`` vs ``convert``) be
6passed together. This module replaces those keyword walls with three immutable, thread-safe configuration objects --
7:class:`Markdown`, :class:`PlainText` and :class:`Html` -- mirroring the :class:`~turbohtml.Policy` sanitizer config and
8the :class:`~turbohtml.Indent`/:class:`~turbohtml.Minify` layout objects already in the API.
9
10The markdown surface is large enough that a single flat config would just move the wall, so :class:`Markdown` groups its
11knobs into themed sub-configs (``Markdown.Headings``, ``Markdown.Links``, ...). Each config validates itself on
12construction and exposes a private ``_unpack`` that yields only the values differing from the renderer's built-in
13defaults, so an empty config reproduces the no-argument rendering exactly and the C renderer keeps its existing
14default handling.
15"""
16
17from __future__ import annotations
18
19from dataclasses import dataclass, field, fields
20from typing import TYPE_CHECKING, Final, Literal
21
22from turbohtml._html import Formatter, Indent, Minify, _register_render_configs
23
24if TYPE_CHECKING:
25 from collections.abc import Callable, Iterable, Mapping
26
27 from turbohtml._html import Element
28
29
30@dataclass(frozen=True)
31class Markdown:
32 """
33 How :meth:`Node.to_markdown` renders a subtree as Markdown.
34
35 Build one and reuse it across threads; every field has a GitHub-Flavored-Markdown default, so ``Markdown()``
36 reproduces the no-argument rendering. The many knobs are grouped into themed sub-configs to keep any one object
37 small -- ``Markdown(links=Markdown.Links(style="reference"))`` rather than a flat ``link_style="reference"``.
38
39 :param headings: how headings are written (ATX, closed ATX, or setext).
40 :param lists: the unordered-list markers.
41 :param inline: inline emphasis, strike, sub/sup, and quote wrappers.
42 :param code: fenced vs indented code blocks and their language.
43 :param links: link style and which links are kept.
44 :param images: how images render and their fallback alt text.
45 :param tables: table rendering and header detection.
46 :param escaping: which Markdown-significant characters are escaped.
47 :param wrapping: word-wrapping of prose and constructs.
48 :param document: line breaks, block spacing, trimming, and transliteration.
49 :param google: reading inline-CSS styling the way a Google Docs export encodes it.
50 :param strip: tags rendered as their text only, dropping the markup; mutually exclusive with ``convert``.
51 :param convert: the only tags to render as Markdown, every other tag dropped to text; mutually exclusive with
52 ``strip``.
53 :param converters: per-tag overrides, each a callable receiving the element and its rendered child Markdown.
54 :raises ValueError: if both ``strip`` and ``convert`` are given, since they are mutually exclusive.
55 """
56
57 @dataclass(frozen=True)
58 class Headings:
59 """
60 How headings are written.
61
62 :param style: ``atx`` (``# h``), ``atx_closed`` (``# h #``), or ``setext`` (underlined).
63 """
64
65 style: Literal["atx", "atx_closed", "setext"] = "atx"
66
67 @dataclass(frozen=True)
68 class Lists:
69 """
70 How unordered lists are marked.
71
72 :param bullets: unordered-list markers, cycled by nesting depth (e.g. ``-*+``).
73 """
74
75 bullets: str = "-"
76
77 @dataclass(frozen=True)
78 class Inline:
79 """
80 Inline text formatting: emphasis, sub/sup, and quote wrappers.
81
82 :param strong: the bold wrapper.
83 :param emphasis: the italic wrapper.
84 :param strikethrough: ``keep`` wraps struck text, ``hide`` drops it.
85 :param ignore_emphasis: render bold/italic/strike content as plain text, no markup.
86 :param sub: the ``<sub>`` wrapper (empty drops the markup).
87 :param sup: the ``<sup>`` wrapper (empty drops the markup).
88 :param hide_strikethrough: in ``google`` mode, drop text a CSS line-through struck.
89 :param quote_open: the opening ``<q>`` wrapper.
90 :param quote_close: the closing ``<q>`` wrapper.
91 """
92
93 strong: str = "**"
94 emphasis: str = "*"
95 strikethrough: Literal["keep", "hide"] = "keep"
96 ignore_emphasis: bool = False
97 sub: str = ""
98 sup: str = ""
99 hide_strikethrough: bool = False
100 quote_open: str = '"'
101 quote_close: str = '"'
102
103 @dataclass(frozen=True)
104 class Code:
105 """
106 How code blocks are rendered.
107
108 :param block_style: ``fenced`` (triple-backtick) or ``indented`` (four-space).
109 :param language: default fence language when the element declares none.
110 :param mark: wrap inline code in ``[code]``/``[/code]`` markers.
111 """
112
113 block_style: Literal["fenced", "indented"] = "fenced"
114 language: str = ""
115 mark: bool = False
116
117 @dataclass(frozen=True)
118 class Links:
119 """
120 Link style and which links are kept.
121
122 :param style: ``inline`` (``[t](url)``) or ``reference`` (collected at the end).
123 :param autolink: emit ``<url>`` when the text equals an absolute href.
124 :param title: use the href as the title when none is given.
125 :param ignore: render link text only, no markup.
126 :param skip_internal: drop ``href="#..."`` fragment links.
127 :param base_url: prefix resolved onto a relative link or image href.
128 """
129
130 style: Literal["inline", "reference"] = "inline"
131 autolink: bool = True
132 title: bool = False
133 ignore: bool = False
134 skip_internal: bool = False
135 base_url: str = ""
136
137 @dataclass(frozen=True)
138 class Images:
139 """
140 How images are rendered.
141
142 :param mode: ``markdown`` (````), ``alt`` (alt text only), ``ignore``, or ``html`` (raw ``<img>``).
143 :param default_alt: alt text used for an image that declares none.
144 """
145
146 mode: Literal["markdown", "alt", "ignore", "html"] = "markdown"
147 default_alt: str = ""
148
149 @dataclass(frozen=True)
150 class Tables:
151 """
152 How tables are rendered.
153
154 :param mode: ``markdown`` (pipe table), ``strip`` (cell text only), or ``html`` (raw ``<table>``).
155 :param header: which row is the header: ``first``, ``detect``, or ``none``.
156 :param cell_blocks: what a table or list *inside* a cell becomes, since a pipe cell holds no block:
157 ``html`` keeps its source markup (and a ``<br>`` for a line break), ``text`` flattens block layout while
158 keeping compact list markers.
159 :param pad: align columns to a common width.
160 """
161
162 mode: Literal["markdown", "strip", "html"] = "markdown"
163 header: Literal["first", "detect", "none"] = "first"
164 cell_blocks: Literal["html", "text"] = "html"
165 pad: bool = False
166
167 @dataclass(frozen=True)
168 class Escaping:
169 """
170 Which Markdown-significant characters are escaped.
171
172 :param mode: ``minimal`` escapes only what a parser needs, ``all`` escapes every Markdown character, and
173 ``none`` leaves prose as written apart from the ``asterisks`` and ``underscores`` choices and a ``|``
174 inside a table cell.
175 :param asterisks: escape literal ``*`` so it is not read as emphasis.
176 :param underscores: escape literal ``_`` so it is not read as emphasis.
177 """
178
179 mode: Literal["minimal", "all", "none"] = "minimal"
180 asterisks: bool = True
181 underscores: bool = True
182
183 @dataclass(frozen=True)
184 class Wrapping:
185 """
186 Word-wrapping of prose and constructs.
187
188 :param width: word-wrap column for prose, or 0 to leave lines unwrapped.
189 :param list_items: extend word wrapping into list-item text.
190 :param links: allow a link or image construct to break across a wrap.
191 """
192
193 width: int = 0
194 list_items: bool = False
195 links: bool = True
196
197 @dataclass(frozen=True)
198 class Document:
199 """
200 Document-level layout: line breaks, block spacing, trimming, and transliteration.
201
202 :param line_break: render a hard break as trailing ``spaces`` or a ``backslash``.
203 :param block_spacing: a ``double`` blank line between blocks, or a ``single`` newline.
204 :param trim: trim document edges: ``strip``, ``lstrip``, ``rstrip``, or ``none``.
205 :param transliterate: fold common non-ASCII typography in prose to ASCII.
206 """
207
208 line_break: Literal["spaces", "backslash"] = "spaces"
209 block_spacing: Literal["double", "single"] = "double"
210 trim: Literal["strip", "lstrip", "rstrip", "none"] = "strip"
211 transliterate: bool = False
212
213 @dataclass(frozen=True)
214 class GoogleDoc:
215 """
216 Reading inline-CSS styling the way a Google Docs export encodes it.
217
218 :param enabled: read inline-CSS styling the way a Google Docs HTML export encodes it.
219 :param list_indent: px of ``margin-left`` per list-nesting level (at least 1).
220 """
221
222 enabled: bool = False
223 list_indent: int = 36
224
225 headings: Markdown.Headings = field(default_factory=Headings)
226 lists: Markdown.Lists = field(default_factory=Lists)
227 inline: Markdown.Inline = field(default_factory=Inline)
228 code: Markdown.Code = field(default_factory=Code)
229 links: Markdown.Links = field(default_factory=Links)
230 images: Markdown.Images = field(default_factory=Images)
231 tables: Markdown.Tables = field(default_factory=Tables)
232 escaping: Markdown.Escaping = field(default_factory=Escaping)
233 wrapping: Markdown.Wrapping = field(default_factory=Wrapping)
234 document: Markdown.Document = field(default_factory=Document)
235 google: Markdown.GoogleDoc = field(default_factory=GoogleDoc)
236 strip: Iterable[str] | None = None
237 convert: Iterable[str] | None = None
238 converters: Mapping[str, Callable[[Element, str], str]] | None = None
239
240 def __post_init__(self) -> None:
241 """Reject the one mutually exclusive pair up front, so the renderer never has to."""
242 if self.strip is not None and self.convert is not None:
243 msg = "strip and convert are mutually exclusive"
244 raise ValueError(msg)
245
246 @classmethod
247 def google_doc(cls) -> Markdown:
248 """
249 Read a Google Docs HTML export: inline-CSS styling drives emphasis, and struck text is dropped.
250
251 :returns: a config tuned for Google Docs export markup.
252 """
253 return cls(
254 inline=cls.Inline(hide_strikethrough=True),
255 google=cls.GoogleDoc(enabled=True),
256 )
257
258 def _unpack(self) -> dict[str, object]:
259 """Flatten to the renderer's keyword names, emitting only values that differ from its defaults."""
260 default = _MARKDOWN_DEFAULT
261 flat = {
262 "heading_style": (self.headings.style, default.headings.style),
263 "bullets": (self.lists.bullets, default.lists.bullets),
264 "strong": (self.inline.strong, default.inline.strong),
265 "emphasis": (self.inline.emphasis, default.inline.emphasis),
266 "strikethrough": (self.inline.strikethrough, default.inline.strikethrough),
267 "ignore_emphasis": (self.inline.ignore_emphasis, default.inline.ignore_emphasis),
268 "sub_symbol": (self.inline.sub, default.inline.sub),
269 "sup_symbol": (self.inline.sup, default.inline.sup),
270 "hide_strikethrough": (self.inline.hide_strikethrough, default.inline.hide_strikethrough),
271 "quote_open": (self.inline.quote_open, default.inline.quote_open),
272 "quote_close": (self.inline.quote_close, default.inline.quote_close),
273 "code_block_style": (self.code.block_style, default.code.block_style),
274 "code_language": (self.code.language, default.code.language),
275 "mark_code": (self.code.mark, default.code.mark),
276 "link_style": (self.links.style, default.links.style),
277 "autolink": (self.links.autolink, default.links.autolink),
278 "link_title": (self.links.title, default.links.title),
279 "ignore_links": (self.links.ignore, default.links.ignore),
280 "skip_internal_links": (self.links.skip_internal, default.links.skip_internal),
281 "base_url": (self.links.base_url, default.links.base_url),
282 "image_mode": (self.images.mode, default.images.mode),
283 "default_image_alt": (self.images.default_alt, default.images.default_alt),
284 "table_mode": (self.tables.mode, default.tables.mode),
285 "table_header": (self.tables.header, default.tables.header),
286 "cell_blocks": (self.tables.cell_blocks, default.tables.cell_blocks),
287 "pad_tables": (self.tables.pad, default.tables.pad),
288 "escape_mode": (self.escaping.mode, default.escaping.mode),
289 "escape_asterisks": (self.escaping.asterisks, default.escaping.asterisks),
290 "escape_underscores": (self.escaping.underscores, default.escaping.underscores),
291 "wrap_width": (self.wrapping.width, default.wrapping.width),
292 "wrap_list_items": (self.wrapping.list_items, default.wrapping.list_items),
293 "wrap_links": (self.wrapping.links, default.wrapping.links),
294 "line_break": (self.document.line_break, default.document.line_break),
295 "block_spacing": (self.document.block_spacing, default.document.block_spacing),
296 "document_strip": (self.document.trim, default.document.trim),
297 "transliterate": (self.document.transliterate, default.document.transliterate),
298 "google_doc": (self.google.enabled, default.google.enabled),
299 "google_list_indent": (self.google.list_indent, default.google.list_indent),
300 }
301 unpacked: dict[str, object] = {name: value for name, (value, base) in flat.items() if value != base}
302 # pass the tag filters through unchanged: the renderer rejects a bare str (so
303 # ``strip="div"`` is an error, not "strip tags d, i, v") and accepts any other iterable
304 if self.strip is not None:
305 unpacked["strip"] = self.strip
306 if self.convert is not None:
307 unpacked["convert"] = self.convert
308 if self.converters is not None:
309 unpacked["converters"] = self.converters
310 return unpacked
311
312
313_MARKDOWN_DEFAULT: Final = Markdown()
314
315
316@dataclass(frozen=True)
317class PlainText:
318 """
319 How :meth:`Node.to_text` and :meth:`Node.to_annotated_text` render a subtree as layout-aware plain text.
320
321 :param width: wrap column for prose, or 0 to leave lines unwrapped.
322 :param links: how to render ``<a>`` targets: ``none`` drops them, ``inline`` shows them after the text,
323 ``footnote`` collects them at the end.
324 :param images: render image alt text instead of skipping images.
325 :param layout: ``extended`` adds blank lines and indentation; ``strict`` keeps the output compact.
326 :param default_image_alt: alt text used for an image that declares none.
327 :param table_cell_separator: string placed between table cells on a row.
328 :param bullet: marker prefixed to each list item.
329 """
330
331 width: int = 0
332 links: Literal["none", "inline", "footnote"] = "none"
333 images: bool = False
334 layout: Literal["extended", "strict"] = "extended"
335 default_image_alt: str = ""
336 table_cell_separator: str = " "
337 bullet: str = "* "
338
339 def _unpack(self) -> dict[str, object]:
340 """Flatten to the renderer's keyword names, emitting only values that differ from its defaults."""
341 default = _PLAINTEXT_DEFAULT
342 return {
343 field_.name: value
344 for field_ in (_PLAINTEXT_FIELDS if type(self) is PlainText else fields(self))
345 if (value := getattr(self, field_.name)) != getattr(default, field_.name)
346 }
347
348
349_PLAINTEXT_DEFAULT: Final = PlainText()
350_PLAINTEXT_FIELDS: Final = fields(PlainText)
351
352
353@dataclass(frozen=True)
354class Html:
355 """
356 How :meth:`Node.serialize` and :meth:`Node.encode` render a subtree as HTML.
357
358 :param formatter: the escape policy for text and attribute values.
359 :param layout: ``None`` emits the compact WHATWG form, an :class:`~turbohtml.Indent` pretty-prints, a
360 :class:`~turbohtml.Minify` minifies.
361 :param sort_attributes: emit each element's attributes in name order rather than source order.
362 :param meta_charset: normalize (or inject) the ``<meta charset>`` declaration to the output encoding.
363 :param xml: emit XML/XHTML syntax -- every empty element self-closes (``<br/>``), foreign SVG and MathML
364 subtrees carry their namespace declarations, and text and attribute values follow the XML escaping rules.
365 The HTML void-element and raw-text special casing (and the ``formatter``/``meta_charset`` options) do not
366 apply; a :class:`~turbohtml.Minify` layout stays HTML. The serializer drops or spaces content XML 1.0 cannot
367 hold -- a C0 control other than tab/newline/return, a surrogate, the noncharacters U+FFFE/U+FFFF, a ``--`` or
368 trailing ``-`` in a comment, an attribute name carrying a forbidden character -- so the output re-parses with
369 :func:`~turbohtml.parse_xml`.
370 """
371
372 formatter: Formatter = Formatter.WHATWG
373 layout: Indent | Minify | None = None
374 sort_attributes: bool = False
375 meta_charset: bool = False
376 xml: bool = False
377
378 def _unpack(self) -> dict[str, object]:
379 """Flatten to the renderer's keyword names, emitting only values that differ from its defaults."""
380 default = _HTML_DEFAULT
381 return {
382 field_.name: value
383 for field_ in (_HTML_FIELDS if type(self) is Html else fields(self))
384 if (value := getattr(self, field_.name)) != getattr(default, field_.name)
385 }
386
387
388_HTML_DEFAULT: Final = Html()
389_HTML_FIELDS: Final = fields(Html)
390
391
392@dataclass(frozen=True)
393class Canonical:
394 """
395 How :meth:`Node.canonicalize` renders a subtree as Canonical XML (c14n).
396
397 Canonical XML is the byte-exact serialization an XML signature signs: attributes are reordered, redundant
398 namespace declarations are dropped, empty elements are written as start-end pairs, and character references are
399 normalized. For a subtree, version ``1.1`` excludes inherited ``xml:id`` and fixes up ``xml:base`` across omitted
400 ancestors.
401
402 :param version: ``1.0`` (`Canonical XML 1.0 <https://www.w3.org/TR/xml-c14n>`_) or ``1.1``
403 (`Canonical XML 1.1 <https://www.w3.org/TR/xml-c14n11/>`_).
404 :param exclusive: use `Exclusive XML Canonicalization <https://www.w3.org/TR/xml-exc-c14n>`_, which renders only
405 the namespaces a subtree visibly uses rather than inheriting every in-scope ancestor declaration.
406 :param with_comments: keep comment nodes, the with-comments variant of each algorithm.
407 :param inclusive_ns_prefixes: in exclusive mode, namespace prefixes promoted to the apex even when unused, so a
408 detached subtree keeps them; only meaningful with ``exclusive``.
409 :raises ValueError: if ``exclusive`` is combined with ``version="1.1"`` (exclusive c14n builds on 1.0 only), or if
410 ``inclusive_ns_prefixes`` is given without ``exclusive``.
411 """
412
413 version: Literal["1.0", "1.1"] = "1.0"
414 exclusive: bool = False
415 with_comments: bool = False
416 inclusive_ns_prefixes: tuple[str, ...] = ()
417
418 def __post_init__(self) -> None:
419 """Reject the option pairs the c14n algorithms leave undefined, so the renderer never has to."""
420 if self.exclusive and self.version == "1.1":
421 msg = "exclusive canonicalization is defined only over version 1.0"
422 raise ValueError(msg)
423 if self.inclusive_ns_prefixes and not self.exclusive:
424 msg = "inclusive_ns_prefixes applies only in exclusive mode"
425 raise ValueError(msg)
426
427 def _unpack(self) -> dict[str, object]:
428 """Flatten to the renderer's keyword names, emitting only values that differ from its defaults."""
429 default = _CANONICAL_DEFAULT
430 return {
431 field_.name: value
432 for field_ in (_CANONICAL_FIELDS if type(self) is Canonical else fields(self))
433 if (value := getattr(self, field_.name)) != getattr(default, field_.name)
434 }
435
436
437_CANONICAL_DEFAULT: Final = Canonical()
438_CANONICAL_FIELDS: Final = fields(Canonical)
439
440_register_render_configs(Markdown, PlainText, Html, Canonical)
441
442
443__all__ = [
444 "Canonical",
445 "Html",
446 "Markdown",
447 "PlainText",
448]