Converts HTML (as Shopify's body_html product field always is) into
Markdown.
The storefront that consumes synced products renders descriptions
through a Markdown component. By CommonMark rules, a block starting
with a raw <p> tag is treated as opaque raw HTML, so Markdown already
written inside Shopify's body_html (sellers routinely hand-write
**bold**/- list text inside <p> tags) is emitted byte-for-byte
instead of rendering. Converting body_html to Markdown at sync time
keeps stored descriptions renderable.
This is a small hand-rolled HTML -> Markdown transform rather than a
LazyHTML/Floki-based one: both are declared only: :test in
mix.exs, and this module runs on the Shopify sync write path in
every environment (not just :test) — pulling a test-only parser into
:dev/:prod would be a wider dependency-footprint change than this
fix calls for.
Supported tags: p, br, h1-h6, ul/ol/li (including lists
nested inside a <li>, rendered as an indented sub-list), table
(thead/tbody/tfoot/tr/td/th), strong/b, em/i, a,
img, plus a transparent div wrapper and HTML entity decoding
( , ", ', numeric character references).
<script>, <style>, <noscript> and <template> elements are
dropped entirely, content included, rather than leaking their raw
text. A bare </> that isn't part of a real tag (e.g. "5 < 10") is
left as plain text instead of being parsed as a tag boundary.
The three markup-significant entities — <, > and &
(and their numeric forms </<, >/>,
&/&, which are normalised to the named form) — are kept
ENCODED in emitted text. CommonMark decodes entities in text itself,
so the rendered result is identical, but decoding them here would turn
a seller's escaped <script> example into a raw HTML block in
the stored Markdown: <script> stripping above only ever sees real
tags, and the storefront's Markdown renderer emits raw HTML blocks
as-is, so under shop_allow_raw_html_descriptions the example would
execute. Keeping them encoded also keeps convert/1 idempotent (a
second pass never sees a tag that wasn't there) and a literal
<b> example stays visible text rather than rendering bold.
Attribute values (href, src, alt) are decoded fully, since they
are URLs/plain strings, not Markdown text.
An <a> whose href scheme is not http, https, mailto or tel
(or a relative path) — javascript:, data:, vbscript:, … — is
emitted as its text alone, without the link: the href is copied
verbatim into [text](href) and the renderer would hand it to the
browser unchanged.
Text with no HTML tag at all is returned byte-for-byte unchanged, which
is what makes convert/1 idempotent — converting an already-converted
(or always-plain) value is a no-op. Markdown already present in text
nodes (**bold**, - item) is never escaped, it is copied through
verbatim.
<table> becomes a GitHub-Flavored-Markdown pipe table (header row,
--- separator, data rows) rather than dropping the structure — a
naive cell-concatenation would silently glue adjacent cells' text
together with no separator, which loses information a reader can't
recover. The header row is whichever row is inside <thead>, or the
first row containing a <th>, or — if neither marker is present —
the table's first row, promoted, so the output is always a valid
table; any other <thead>-tagged rows are folded into the body
instead of being dropped. Cell text has its own line breaks collapsed
to spaces and | escaped to |, since a table row is a single
Markdown line.
Summary
Functions
Converts html to Markdown. Text that contains no HTML tag at all is
returned unchanged.