An HTML-to-Markdown conversion can preserve every visible word and still lose the document.
The reason is structural: HTML can encode presentation, application metadata, arbitrary element attributes, interactive behavior, and table spans. Markdown has a deliberately smaller document model. Conversion is therefore a policy for choosing information, not the inverse of HTML parsing.
I tested a browser-local converter with Node.js 25.3.0, Turndown 7.2.4, and Marked 18.0.7. Each fixture records the input HTML, emitted Markdown, and HTML rendered from that Markdown. The goal was not visual similarity. It was to identify which semantic invariants survived.
Start with a case that should work
This input uses structures with direct Markdown counterparts:
<h1>Release notes</h1>
<p>Ship the <strong>reviewed</strong> draft with the
<a href="https://example.com/spec">source specification</a>.</p>
<ul>
<li>Preserve hierarchy
<ul><li>Verify the final link</li></ul>
</li>
</ul>
The output was:
# Release notes
Ship the **reviewed** draft with the [source specification](https://example.com/spec).
- Preserve hierarchy
- Verify the final link
Rendering it again restored one h1, one strong, the same link target, and two list levels. The HTML whitespace and tag spelling changed, but the document meaning under test survived.
That distinction matters. Byte equality is neither expected nor useful here. A migration test should ask whether the required structure survived.
One difficult fixture exposes the many-to-one mapping
Now add presentation metadata, inline elements without portable Markdown equivalents, and a merged table cell:
<section class="release-card" style="display:grid" data-build="42">
<h2 style="color:red">Styled release</h2>
<p><mark>Highlighted</mark> H<sub>2</sub>O and x<sup>2</sup>.</p>
<table>
<tr><th>Item</th><th>Status</th></tr>
<tr><td rowspan="2">Parser</td><td>Ready</td></tr>
<tr><td>Checked</td></tr>
</table>
</section>
The actual Markdown was:
## Styled release
Highlighted H2O and x2.
Item
Status
Parser
Ready
Checked
The text order survived. The class, inline style, build metadata, highlight, subscript, superscript, table grid, and rowspan did not. Re-rendering produced a heading followed by plain paragraphs. No later converter can infer which paragraphs used to be cells.
The Turndown documentation explains why: recognized elements use conversion rules; the default rule for an unrecognized element emits its text content. Table support is supplied by a GFM plugin or custom rules rather than the base CommonMark rule set.
GFM tables still would not solve every case. The GFM specification defines a header row, delimiter row, and data rows whose cells contain inline content. It does not provide HTML-style row spans, column spans, or arbitrary block structure inside cells.
Malformed HTML adds a parsing stage you may forget
I also tested invalid nesting:
<p>Before<div>Inside</div>After</p>
<ul><li>One<li>Two</ul>
It became:
Before
Inside
After
- One
- Two
The HTML parser first constructed a corrected DOM tree. The converter then traversed that tree. The WHATWG parsing algorithm defines error-recovery behavior, so the converter may preserve the browser's interpretation rather than the author's exact source boundaries.
If a failed migration is inspected only after rendering, these stages become indistinguishable:
- the source HTML was already invalid;
- the HTML parser repaired it;
- the conversion rule flattened a node;
- the destination Markdown dialect interpreted the output differently.
Keep the original fixture and every intermediate representation if you need to locate the first semantic change.
Boundary behavior needs exact output, not a green message
The fourth fixture combined code metadata, non-breaking spaces, a line break, a script, and a style block:
<style>.release { color: red }</style>
<script>window.__shouldNotRun = true</script>
<pre><code class="language-js">if (a < b) {
console.log("x");
}</code></pre>
<p>alpha beta<br>gamma</p>
With the tested rules, script and style content were removed. The code and its js language survived, <br> became a Markdown hard break, and both U+00A0 characters remained:
```js
if (a < b) {
console.log("x");
}
```
alpha beta
gamma
Those observations are version- and rule-specific. They are not proof that arbitrary HTML is safe, that every class becomes a language, or that every renderer treats non-breaking spaces identically.
Test a semantic contract
Instead of comparing HTML strings, define the structures your migration promises:
const expected = {
headings: [{ level: 1, text: "Release notes" }],
links: [
{ text: "source specification", href: "https://example.com/spec" },
],
listDepth: 2,
code: { language: "js", includes: 'console.log("x")' },
};
For a real content migration, I would assert:
- heading count, level, and order;
- link text and destination;
- list item count and nesting depth;
- code text, fence, and language;
- image source, alt text, and relative-URL base;
- table row and column counts;
- first and last meaningful text;
- an explicit inventory of discarded scripts, styles, and metadata.
I repeated the four public, non-sensitive fixtures on MDFold's HTML-to-Markdown tool. Its browser output matched the local Turndown script exactly. Headings, links, nested lists, code fences, and the tested language marker survived. The tested table flattened to text, so table structure remains a manual-review boundary in the current base converter. I also verified the workflow at a 390px viewport with no horizontal overflow.
That last limitation is why a converter's success message should never be the acceptance test.
Preserve the source when the source matters
Markdown is a good derived format for editing, diffing, documentation, and long-term text maintenance. It is not a forensic copy of a webpage.
If you must preserve CSS, interactive controls, form behavior, arbitrary attributes, complex tables, or exact invalid source spelling, retain the original HTML alongside the Markdown. Record the converter version, options, plugins, fixtures, and warnings so the derivation can be reproduced.
The engineering question is not “Did the converter finish?” It is “Which semantic contract did it satisfy, and which information did we intentionally abandon?”
When visual fidelity and semantic maintainability conflict in a migration, which one belongs in your acceptance criteria?












