I migrate content for a living, in the sense that anyone who has run a WordPress site for a decade eventually does. When I moved a batch of old posts out of a page builder and into a Markdown-based docs system, I discovered what "clean HTML" really means in the wild: <p> tags with inline styles, empty <span>s wrapping single words, <div>s three levels deep around a paragraph, and the occasional tag that was simply never closed. Pasting that into a Markdown field gave me a wall of literal angle brackets. Copy-pasting the rendered text lost every link, heading, and list. Neither shortcut worked, and I ended up hand-cleaning articles for an afternoon before I got sensible about it.
Getting sensible meant treating HTML-to-Markdown as what it is: a structured transformation with a clear set of rules, not a copy-paste. A heading element becomes a # line. Bold becomes **. A link becomes [text](url). A table becomes a pipe table. Once you have those mappings and a parser tolerant enough to survive real-world markup, the conversion is boring and reliable - which is exactly what you want. I build toolz.dev and put a browser-based HTML to Markdown converter there that applies these rules, but this guide is about the rules themselves so you understand what the output should look like and why.
TL;DR: To convert HTML to Markdown, map block elements to their Markdown equivalents (
<h2>→##,<ul><li>→-,<blockquote>→>,<pre><code>→ a fenced block), map inline elements (<strong>→**,<em>→*,<a>→[text](href),<img>→), turn<table>into a GitHub Flavored Markdown pipe table, decode HTML entities back to real characters, and drop<script>/<style>. Use a parser tolerant of unclosed tags, and do it in the browser so your content never gets uploaded.
Why convert HTML to Markdown at all?
Markdown is the portable format of the modern developer web. README files, documentation sites, static blogs (Hugo, Jekyll, Astro, Next.js content), note apps like Obsidian and Bear, GitHub issues, and increasingly the prompts and context we feed to AI models - all of them speak Markdown. It's plain text, it diffs cleanly in Git, and it survives being moved from one system to another without dragging a pile of presentation markup along with it.
HTML, by contrast, is what you end up with whenever content passes through a browser or a WYSIWYG editor. Copy a section from a web page, export from a CMS, pull the body of an article out of an API, or grab formatted text from a rich-text field, and you get HTML - usually cluttered HTML, full of wrapper elements and inline styles you don't want in a clean document. The conversion is the bridge from "content trapped in presentation markup" to "content I can version, edit, and republish anywhere."
The scenarios recur constantly once you notice them. Migrating a blog off WordPress or Medium. Pulling documentation out of a legacy HTML help system. Turning a scraped article into a Markdown note. Converting an email newsletter's HTML into a Markdown draft. Feeding a web page's content to an LLM as clean context instead of raw HTML that wastes tokens on <div> soup. In every case the manual alternatives - retyping, or copy-pasting rendered text and rebuilding the formatting by hand - are slow and lossy. A rule-based converter is neither.
How does HTML map to Markdown?
The mapping splits naturally into two layers: block-level structure and inline formatting.
Block elements define the document's skeleton, and each has a direct Markdown counterpart:
<h1>–<h6>become#through######heading lines.<p>becomes a paragraph separated by blank lines.<ul>/<li>become-bulleted lists;<ol>/<li>become1.numbered lists, and nested lists indent by two spaces.<blockquote>prefixes each line with>.<hr>becomes---.<pre><code>becomes a fenced code block with triple backticks.<table>becomes a GitHub Flavored Markdown pipe table.
Inline elements decorate text within those blocks:
<strong>and<b>become**bold**.<em>and<i>become*italic*.<code>becomes`inline code`.<a href="...">becomes[link text](href), keeping thetitleattribute when present.<img>becomes.<del>and<s>become~~strikethrough~~(a GitHub Flavored Markdown extension).<br>becomes a hard line break - two trailing spaces before the newline.
Run those rules over a real block of HTML and the structure survives intact. Given this input:
<article>
<h1>Getting Started</h1>
<p>Convert <strong>HTML</strong> into clean <em>Markdown</em>.</p>
<ul>
<li>Paste from a CMS</li>
<li>Get portable output</li>
</ul>
<pre><code class="language-js">const md = convert(html)</code></pre>
</article>
you get:
# Getting Started
Convert **HTML** into clean *Markdown*.
- Paste from a CMS
- Get portable output
```js
const md = convert(html)
```
Two details in that output are worth calling out. First, the <article> wrapper contributed nothing - structural containers like <article>, <section>, and <div> are transparent; the converter recurses into them and renders their children as blocks. Second, the code fence picked up js as its language. That's because the <code> element carried class="language-js", the convention syntax highlighters use, and a good converter reads that class to label the fence. It's a small thing that makes the difference between a code block that highlights on your docs site and one that doesn't.
What makes a converter survive real-world HTML?
The rules above are the easy part. The reason hand-rolled regex converters fail - and I've written and thrown away a couple - is that real HTML breaks assumptions constantly. Three problems come up again and again.
Unclosed tags. Browsers are famously forgiving: <p>one<p>two renders as two paragraphs because a <p> implicitly closes when another block element opens, and <li>a<li>b is two list items for the same reason. A naive parser that only closes elements on an explicit </p> will nest the second paragraph inside the first and mangle everything downstream. A robust converter applies the same implied-end-tag rules the HTML spec defines: opening a block-level element closes an open paragraph; opening a <li> closes the previous one. My converter does this, which is why pasting sloppy CMS output usually just works.
<script> and <style> blocks. These contain code and CSS, not content, and their contents must be taken verbatim (a < inside JavaScript is not a tag) and then dropped entirely. Forget to special-case them and you get JavaScript source bleeding into your Markdown.
HTML entities. Web content is littered with &, ©, —, , and numeric references like ’. In Markdown - which is plain text - you want the actual characters: &, ©, —, and a real apostrophe. Decoding named and numeric entities is not optional; skip it and your clean Markdown reads like view-source.
This is why I stopped trying to convert HTML with find-and-replace and built a proper tolerant parser instead. It tokenizes the HTML into a tree - handling comments, void elements like <br> and <img>, uppercase tag names, and unquoted attributes - and then walks that tree emitting Markdown. Notably, it doesn't rely on the browser's DOM, which means the same logic runs on a server too, but the practical payoff for you is simple: it doesn't throw on the messy markup that real websites are made of.
How do I use the converter?
On toolz.dev/tools/html-to-markdown, paste your HTML into the left pane and the Markdown appears on the right as you type - there's no upload and no Convert button to hunt for. Load the sample if you want to see every supported construct at once.
A few options cover the common preferences. Pick your bullet character (-, *, or +) for unordered lists to match your project's style. Keep GitHub Flavored Markdown on for tables and strikethrough, or off if your target only accepts CommonMark. Toggle "keep links" off when you want the prose but not the URLs - handy for turning a link-heavy article into clean reading text - and "keep images" off to strip images entirely. The stats row shows words, headings, links, images, code blocks, and an estimated reading time, which is a quick sanity check that the structure came across before you paste the result somewhere.
As with everything on the site, the conversion is 100% client-side. The parser is plain JavaScript running in your browser, so pasted pages, internal documentation, and unpublished drafts are never sent anywhere. That's the right default for content work - you shouldn't have to upload a draft to a stranger's server just to reformat it - and it means the tool keeps working offline. I go deeper on why browser-side processing matters in the data privacy tools guide.
HTML vs Markdown: when to use which
Converting between them is easy; knowing which you want is the real decision.
| Aspect | HTML | Markdown |
|---|---|---|
| Primary purpose | Rendering in a browser | Writing and storing text |
| Readability as source | Poor (tag-heavy) | Excellent (reads as plain text) |
| Git diffs | Noisy | Clean |
| Portability across systems | Low | High |
| Precise layout control | Full | Limited by design |
| Where it lives | Web pages, WYSIWYG output | READMEs, docs, notes, static sites |
| Learning curve | Steep | Minutes |
Markdown deliberately trades layout control for simplicity, which is why it wins for content you write, version, and move around, and loses when you need pixel-level presentation. Convert HTML to Markdown when you're capturing or migrating content; keep HTML when you're building a page. And when you need to go the other way - Markdown into HTML for a CMS field or email template - the Markdown to HTML converter is the mirror image of this tool and shares the same conventions, so the two round-trip cleanly for common structures.
Where this fits in a content workflow
Format conversion is rarely the whole job; it's one step. After I convert an article to Markdown I'll usually run the text through a word counter to check length against a target, and if the source had HTML entities that need encoding back for some other destination, the HTML entity encoder/decoder handles the reverse. When I'm cleaning up hand-written HTML before conversion, an HTML minifier strips the noise first. These small tools chain together into a repeatable pipeline, which is the whole idea behind how I think about a browser-based toolkit - I laid out that philosophy in the web developer toolkit guide and the broader coding tools guide.
Common mistakes converting HTML to Markdown
Copy-pasting rendered text instead of converting the HTML. You lose links, headings, and lists - all the structure. Convert the actual markup.
Ignoring the code language class. If your fenced blocks come out without a language and don't highlight, the converter dropped the class="language-x" hint. A good one reads it.
Assuming CommonMark and GFM are the same. Tables and strikethrough are GitHub Flavored Markdown extensions, not core CommonMark. If your destination is strict CommonMark, a table won't render - convert with GFM off and handle tables another way.
Trusting a converter that uploads your content. Plenty of "free" online converters POST your HTML to a server. For anything unpublished or internal, that's a leak. Use a client-side tool.
FAQ
How do I convert HTML to Markdown?
Paste your HTML into the editor and the Markdown appears immediately - there is no file to upload and no convert button to press. Adjust the bullet style or link handling if you like, then copy the Markdown or download it as an .md file.
Does the converter handle tables?
Yes. With GitHub Flavored Markdown enabled, an HTML <table> is turned into a Markdown pipe table: the first row becomes the header, a divider row is inserted, and the remaining rows become the body. Pipe characters inside cells are escaped so they do not break the table.
What happens to links and images?
Anchors become [link text](href) and images become , preserving the title attribute when present. If you turn off "keep links" the anchor text is kept but the URL is dropped, and turning off "keep images" removes images entirely - useful when you only want the prose.
Are code blocks and inline code preserved?
Yes. A <pre><code> block becomes a fenced code block, and if the code element carries a class like "language-js" that language is added to the fence. Inline <code> spans are wrapped in backticks, and the code text itself is never escaped or reformatted, so snippets stay exactly as written.
Will it work on messy HTML copied from a website?
That is what it is built for. The parser is tolerant of unclosed tags, uppercase element names, unquoted attributes and stray comments, and it strips
