Publish the same article to three platforms and you will discover they cannot agree on what an article is. One wants Markdown. One wants HTML. One wants structured CMS fields whose names differ per site. Write the integration naively and you end up with three copies of your formatting logic that drift apart.
The bug that starts it
The usual first design is a plain-text body passed to every adapter. It works until someone writes bold text. On a Markdown platform the asterisks render as formatting. On an HTML platform, a body built like this:
def html_body(text):
return "".join(f"<p>{escape(p)}</p>" for p in text.split("\n\n"))
publishes the literal characters **bold** to the reader. The escaping is correct — it is preventing injection — but it also makes rich formatting impossible to express. You cannot fix this by escaping less. You fix it by not passing prose as the transport format.
Model the document, render per target
Represent content as blocks and inline spans, then render that structure to whatever each platform wants. The document is the source of truth; Markdown and HTML are both just output formats.
@dataclass
class Span:
text: str
bold: bool = False
italic: bool = False
code: bool = False
href: str | None = None
# Blocks: Paragraph, Heading, BulletList, Quote, CodeBlock, Image, Video, Divider
Flat spans beat a nested tree
It is tempting to model inline formatting as a tree so bold can contain italic can contain a link. For article prose the extra generality buys almost nothing and makes every renderer recursive. Boolean flags plus an optional href cover the combinations real posts actually use.
Escape at the right moment
The two renderers have opposite hazards, and both are easy to get backwards.
**HTML: **escape the text run first, then wrap it in tags. Escape after wrapping and you mangle your own markup into visible <strong>.
**Markdown: **escape the characters Markdown treats as syntax — asterisk, underscore, backtick, brackets — inside plain runs only. Skip this and a filename like file_name_here silently italicises mid-word. Do it inside inline code and readers see stray backslashes.
_MD_SPECIALS = re.compile(r"([\\`*_\[\]<>])")
def md_span(s: Span) -> str:
body = f"`{s.text}`" if s.code else _MD_SPECIALS.sub(r"\\\1", s.text)
if not s.code:
if s.bold and s.italic: body = f"***{body}***"
elif s.bold: body = f"**{body}**"
elif s.italic: body = f"*{body}*"
return f"[{body}]({s.href})" if s.href else body
Not every block survives every target
A raw-HTML escape hatch is useful on HTML targets and actively harmful on Markdown ones, where raw HTML inside Markdown renders inconsistently. Rather than emit it and hope, drop it explicitly on Markdown targets. Same for video: an HTML5 <video> element where raw HTML works, and a labelled link where it does not, because a bare .mp4 URL does not auto-embed on most Markdown platforms.
A renderer that silently emits something the target cannot display is worse than one that refuses. At least the refusal is visible.
Canonical URLs are not universal
If you syndicate, you want a canonical pointing at the original. Only some platforms let you set one through the API. Where the field does not exist, the honest fallback is a visible Originally published at line — it credits the source to readers and gives crawlers a real link. Do not pretend you set a canonical tag you could not set.
What this buys
- Formatting logic exists once, not once per platform.
- Adding a fourth platform is a payload mapping, not a rewrite.
- Escaping rules live in one place where they can be tested.
- The same document renders correctly everywhere, including bold, links, lists, code, and images.
This is the model behind DM IQ's multi-platform publishing across nineteen channels.
Top comments (0)