"""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}
" 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 == "bulletList": 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}{tag}>"
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'