"""Render Patreon's post body to HTML. Patreon deprecated the flat `content` HTML field (it returns `null` now, on the feed AND the detail endpoint, for every post type) and moved the real body to `content_json_string` — a ProseMirror/TipTap document (a JSON string of typed nodes + marks), returned only under the DEFAULT post fieldset. This module converts that doc to the HTML the rest of the pipeline already expects (it then flows through the existing nh3 sanitizer + inline-image localization). #842. Built from the observed Patreon doc shape: doc → [paragraph|heading|image|list…]; paragraph content → [text(+marks: bold/italic/underline/strike/code/link), hardBreak, image]. Unknown node types degrade to rendering their children so we never silently drop text. """ from __future__ import annotations import json from html import escape # ProseMirror text-mark type → wrapping HTML tag (link handled separately — it # needs an href attribute). _MARK_TAGS = { "bold": "strong", "strong": "strong", "italic": "em", "em": "em", "underline": "u", "strike": "s", "strikethrough": "s", "code": "code", } def post_body_html(attrs: dict | None) -> str | None: """Resolve a Patreon post's body HTML from its `attributes`. Prefers the legacy flat `content` HTML (older posts still carry it); falls back to converting the `content_json_string` ProseMirror doc (current posts). Returns None when neither holds a body. The ONE place body-source precedence lives, so the feed-parse path and the detail-fetch path can't diverge. """ if not isinstance(attrs, dict): return None content = attrs.get("content") if isinstance(content, str) and content.strip(): return content return prosemirror_to_html(attrs.get("content_json_string")) def prosemirror_to_html(doc_json: str | None) -> str | None: """Render a ProseMirror/TipTap doc (a JSON string) to HTML, or None for an empty / blank / unparseable doc.""" if not isinstance(doc_json, str) or not doc_json.strip(): return None try: doc = json.loads(doc_json) except (ValueError, TypeError): return None if not isinstance(doc, dict): return None return _render_nodes(doc.get("content")) or None def _render_nodes(nodes: object) -> str: if not isinstance(nodes, list): return "" return "".join(_render_node(n) for n in nodes if isinstance(n, dict)) def _render_node(node: dict) -> str: ntype = node.get("type") if ntype == "text": return _render_text(node) if ntype == "hardBreak": return "
" if ntype == "image": return _render_image(node) if ntype == "horizontalRule": return "
" inner = _render_nodes(node.get("content")) if ntype == "paragraph": return f"

{inner}

" if ntype == "heading": level = (node.get("attrs") or {}).get("level", 1) if not isinstance(level, int) or not 1 <= level <= 6: level = 1 return f"{inner}" if ntype == "blockquote": return f"
{inner}
" if ntype == "bulletList": return f"" if ntype == "orderedList": return f"
    {inner}
" if ntype == "listItem": return f"
  • {inner}
  • " if ntype == "codeBlock": return f"
    {inner}
    " # Unknown / unhandled container — emit children so text is never dropped. return inner def _render_text(node: dict) -> str: text = node.get("text") if not isinstance(text, str) or not text: return "" out = escape(text) for mark in node.get("marks") or []: if not isinstance(mark, dict): continue mtype = mark.get("type") if mtype == "link": href = (mark.get("attrs") or {}).get("href") or "" out = f'{out}' else: tag = _MARK_TAGS.get(mtype) if tag: out = f"<{tag}>{out}" return out def _render_image(node: dict) -> str: attrs = node.get("attrs") or {} src = attrs.get("src") if not isinstance(src, str) or not src: return "" alt = attrs.get("alt") or "" return f'{escape(str(alt), quote=True)}'