All articles
Converters
37 min readBy DevUtilX Team

Markdown to HTML: Syntax, CommonMark and Rendering

Learn Markdown syntax, how CommonMark fixed its ambiguities, how parsers render HTML, and how to sanitise output safely, with tested examples and a free converter.

Markdown to HTML: Syntax, CommonMark and Rendering

You write a README, a changelog or a help article in Markdown, and it looks perfect in your editor. Then you paste it into a CMS, an email tool or a static site generator, and the list collapses into a paragraph, the table turns into pipes, or a stray underscore italicises half a sentence. Markdown looks like one language, but it behaves like a family of dialects, and the HTML you get depends on which parser reads it. This guide explains where Markdown came from, which ambiguities CommonMark settled, how a parser turns text into HTML, and what to watch for in security and accessibility. It is written for developers, technical writers and anyone who ships content produced from Markdown.

By the end you will know the full syntax and its edge cases, understand the two-phase design of a parser, be able to predict the HTML for tricky input, and know how to render untrusted Markdown safely. Every output shown below was produced by running a real parser in a sandbox, so you can reproduce it.

What is Markdown?

Markdown is a lightweight plain-text format for writing structured documents. You mark headings with #, emphasis with asterisks and lists with hyphens, and a converter turns that text into HTML. The goal is a source file that reads naturally as plain text and still produces clean markup. Compare these two ways of writing the same sentence:

Markdown is *easy* to read.
<p>Markdown is <em>easy</em> to read.</p>

The first is something you can read in an email without any rendering. The second is something a browser needs. Markdown exists to let you write the first and publish the second.

Two ideas are worth holding onto from the start. First, Markdown is a writing format, not a page layout language. It has no concept of fonts, colours, columns or exact spacing. That comes from the stylesheet applied to the generated HTML. Second, the output is HTML, so everything HTML can do remains available through an escape hatch: you can embed raw HTML directly in a Markdown document. That convenience has serious security consequences, which we cover in a later section.

A short history

Markdown was created by John Gruber, with substantial input from Aaron Swartz, and released in 2004. The original implementation was a Perl script called Markdown.pl, accompanied by a prose description of the syntax on Gruber's site, Daring Fireball. The design philosophy was explicit: a Markdown document should be publishable as-is, as plain text, without looking like it has been marked up with tags.

The syntax borrowed from conventions that people already used in plain-text email and Usenet posts: asterisks around a word for emphasis, hyphens for bullet points, angle brackets to quote earlier messages. Anyone who had written a plain-text email already knew most of Markdown.

The format spread quickly, because writing was faster than typing tags and because the Perl script was easy to port. Within a few years there were implementations in nearly every popular language. That popularity created a problem.

The ambiguity problem

Gruber's description was a prose document plus one reference script. It was not a specification with precise rules and a test suite. Where the prose was silent or vague, each implementer made a guess, and the guesses differed. Questions that sound trivial had no agreed answer:

  • Can a list interrupt a paragraph, or does it need a blank line first?
  • How many spaces of indentation make a nested list item?
  • What happens when emphasis markers are nested or unbalanced, as in *foo**bar**baz*?
  • Does #Heading without a space count as a heading?
  • If a list has blank lines between items, are the items wrapped in paragraphs?
  • Where does a block of raw HTML end, and is Markdown processed inside it?

The result was that the same document produced different HTML in different tools. Moving content between systems meant discovering those differences one broken page at a time. Many sites also added private extensions: tables, fenced code blocks, task lists, strikethrough, footnotes. Each added its own syntax, which multiplied the incompatibilities.

CommonMark

Around 2014 a group of implementers, led by John MacFarlane (the author of the Pandoc converter), set out to write a precise specification. The project was first announced under a different name and was renamed CommonMark after objections that the earlier name implied an official status. CommonMark consists of a written specification with hundreds of examples, each pairing an input with the exact expected HTML, plus reference implementations in C and JavaScript. Every example doubles as a test case, so a parser either passes or fails against the spec.

CommonMark did not try to add features. It tried to settle the ambiguities in the original syntax so that the core of Markdown behaves identically everywhere. Where Gruber's text allowed several readings, the spec picked one and wrote it down with examples.

Extensions came next. GitHub published its own specification, GitHub Flavored Markdown, defined as a strict superset of CommonMark. It adds tables, task list items, strikethrough and some automatic link recognition on top of the CommonMark core. Because it is built on CommonMark, the differences between the two are small and documented.

There is also a standards-track footnote. RFC 7763 registers the text/markdown media type, and a companion document, RFC 7764, records the common variants. The registration is deliberately permissive about which flavour a file uses, which tells you something about the state of the ecosystem: Markdown is a convention with a well-specified core, not a single standard.

The syntax tour

This section walks through every major construct, with the HTML a CommonMark-based parser generates. All outputs in this guide were produced by running the marked JavaScript library (version 18.1.3 in the sandbox used for this article). Your tool may format whitespace slightly differently, but the elements should match.

Headings

There are two heading styles. ATX headings use one to six # characters followed by a space:

# Level one
## Level two
###### Level six

Setext headings underline a line of text with = for level one or - for level two:

Title
=====

Subtitle
--------

ATX is more common because it supports all six levels. The space after the hashes matters. Under CommonMark, #NoSpace is not a heading; it is a paragraph that happens to start with a hash character. We ran this to confirm: #NoSpace came out as <p>#NoSpace</p>. Some older parsers accepted it as a heading, which is one of the ambiguities the specification closed.

Emphasis and strong emphasis

Single asterisks or underscores produce <em>, and doubled markers produce <strong>:

*emphasis* or _emphasis_
**strong** or __strong__
***both***

Asterisks and underscores differ in one practical way. Underscores inside a word do not trigger emphasis in CommonMark, so snake_case_word stays intact. Asterisks do work inside words, so un*believ*able emphasises the middle. We tested _snake_case_word_ and snake_case_word, and the output was <em>snake_case_word</em> and snake_case_word. The outer underscores, which sit at word boundaries, produced emphasis, and the inner ones did not. If you need intra-word emphasis, use asterisks.

Nesting works as you would expect, and even awkward input has defined behaviour. The string *foo**bar**baz* produced <em>foo<strong>bar</strong>baz</em>. CommonMark resolves such cases with a documented algorithm based on delimiter runs, rather than by guessing.

Paragraphs and line breaks

A paragraph is one or more consecutive lines separated from other blocks by blank lines. A single newline inside a paragraph is a soft break: the HTML source keeps the newline, but browsers render it as a space. To force a visible <br>, end the line with two or more spaces, or with a backslash:

line one␠␠
line two\
line three

In that example, the two trailing spaces after "line one" are shown as ␠ so you can see them. Our run produced <p>line one<br>line two<br>line three</p>. Trailing spaces are invisible in most editors and easy to strip by accident, which is why the backslash form is more robust. Many editors and linters trim trailing whitespace on save, which silently removes hard breaks. If your line breaks vanish, check for that first.

Some parsers offer a "breaks" option that turns every soft newline into <br>. GitHub comments behave that way. It is not part of the CommonMark core, so a document that depends on it will look different in other tools.

Lists

Unordered lists begin each item with -, * or +, followed by a space. Ordered lists use a number followed by a period or a closing parenthesis:

- apples
- pears
  - nested pear

1. first
2. second

The parser generated nested <ul> elements inside the list item and a separate <ol> for the numbered list. Three behaviours cause most of the confusion.

The starting number is respected, the rest are ignored. An ordered list that begins 3. renders as <ol start="3">, and the following numbers are ignored: the sequence 3. and 4. gave a list whose items the browser numbers consecutively. So 1. 1. 1. and 1. 2. 3. render the same, and writing every number as 1. keeps diffs small when you reorder items.

Changing the bullet character starts a new list. The input * a, + b, - c produced three separate <ul> elements, one per item. This is a CommonMark rule, and it surprises people who mix markers by accident.

Blank lines between items make the list "loose". When items are separated by blank lines, or an item contains multiple blocks, each item's content is wrapped in <p> tags. We saw this with - a, a blank line, - b. Each item came out as <li><p>a</p></li>. A tight list, with no blank lines, renders items without paragraphs. The visual difference is extra vertical space in most stylesheets, so a stray blank line in the middle of a list changes the whole list, not just one gap.

A list item can contain multiple paragraphs, code blocks or nested lists, as long as continuation lines are indented to align with the item's content. For 1. one the content starts at column 4, so a continued paragraph needs at least three spaces of indentation. Our run of 1. one, a blank line, para in item, then 2. two produced two list items, the first holding two paragraphs. The rule is to indent continuation content to the first character after the marker. People commonly use four spaces, which works for most cases, but it goes wrong with a code block inside a list item, where you must add four more spaces for the code on top of the item indentation.

CommonMark also lets a bullet list interrupt a paragraph without a blank line. The input Intro followed directly by - list right after rendered a paragraph and then a list. Some older parsers needed a blank line, so adding one remains good practice for portability.

Inline links put the target in parentheses right after the link text, with an optional title:

[DevUtilX](https://example.com "Optional title")

Reference links separate the text from the destination. This keeps long URLs out of the prose and lets you reuse one URL in several places:

Read the [spec][cm] and the [guide][cm].

[cm]: https://commonmark.org/ "CommonMark"

Reference labels are matched case-insensitively. A definition can appear anywhere in the document, including at the bottom, and it produces no output by itself. We confirmed that [docs][d] plus [d]: https://example.com "Example" produced <a href="https://example.com" title="Example">docs</a>, and that the definition line left no trace in the HTML.

Autolinks wrap a URL in angle brackets, <https://example.com>, and work in every CommonMark parser. GitHub Flavored Markdown also recognises bare URLs and www. addresses without brackets, as an extension. In our run, https://example.com and www.example.com became two links, and the www one was given an http:// prefix. That extension is not in plain CommonMark, so do not rely on it where the flavour is unknown.

Images

An image looks like a link with an exclamation mark in front. The text in brackets becomes the alt attribute:

![A grey cat asleep on a keyboard](cat.png "Optional title")

The output was <img src="cat.png" alt="A grey cat asleep on a keyboard" title="Optional title">. Markdown has no syntax for width, height, loading behaviour or captions. If you need those, you either use raw HTML, rely on a flavour or plugin that supports attributes, or let CSS handle sizing. The accessibility section below explains why the alt text deserves care.

Code spans, indented blocks and fenced blocks

Backticks mark inline code. The content is escaped and not processed for emphasis or links, so <div> and * inside a code span appear literally. To include a backtick, wrap the span in a longer run of backticks.

CommonMark supports two block forms. An indented code block is any run of lines indented by four spaces or one tab. A fenced block is surrounded by three backticks or three tildes, with an optional info string naming the language:

```js
const a = 1 < 2;
```

This produced <pre><code class="language-js">const a = 1 &lt; 2;</code></pre>. Notice two things. The less-than sign was escaped to &lt;, so code samples cannot inject markup. And the language became a CSS class, language-js, which is a hook for syntax highlighters. The parser does not colour anything itself.

Fenced blocks were not in Gruber's original syntax. Several sites added them separately, and CommonMark adopted them into the core. Prefer fences over indentation: they let you declare a language, they do not depend on whitespace that editors may rewrite, and they are easier to nest inside list items.

One trap with indented code is that it cannot interrupt a paragraph. We ran Text followed by a line indented four spaces, and the second line was treated as a paragraph continuation, not code: the output was a single <p> containing both lines.

Blockquotes

A line starting with > is part of a blockquote. Blockquotes can contain any other block, including lists, code and further quotes:

> A quotation
> that spans lines.
>
> > A nested quote.

The result was a <blockquote> containing a paragraph and a second nested <blockquote>. Consecutive > lines without a blank > line between them merge into one paragraph, which is why the first two lines joined into one paragraph in our output. Use a bare > line to separate paragraphs inside a quote.

Horizontal rules

Three or more hyphens, asterisks or underscores on a line by themselves produce <hr>. Beware that --- directly under a line of text turns that text into a level-two Setext heading, not a rule. Leave a blank line above a rule to avoid it. The same three hyphens have a third job at the very top of a file, which we discuss under front matter.

Tables (GFM extension)

Tables are not part of the CommonMark core. The GFM specification defines them as pipes and a delimiter row of hyphens, with colons to set alignment:

| Name | Qty |
|:-----|----:|
| Pen  |   2 |
| Ink  |   9 |

A left colon aligns left, a right colon aligns right, and colons on both sides centre the column. Our run produced a <table> with <thead> and <tbody>, header cells as <th> with align="left" and align="right" attributes, and data cells as <td>. The align attribute is old-fashioned HTML. Some renderers emit style="text-align:left" instead, and the visual result is the same.

Cell contents are inline only. You cannot put a list, a code block or multiple paragraphs in a table cell, so use raw HTML for that. A literal pipe inside a cell must be escaped as \|. We verified that 1 \| 2 produced a cell reading 1 | 2. The alignment of the pipes in your source does not matter, so you can leave a messy table alone or tidy it for readability.

Task lists and strikethrough (GFM extensions)

A list item that begins with [ ] or [x] becomes a checkbox. We rendered - [x] done and - [ ] todo and got <input checked="" disabled="" type="checkbox"> and an unchecked, disabled one. The checkboxes are disabled on purpose: the output is a static document. On GitHub, clicking a checkbox in an issue edits the source Markdown, but that behaviour belongs to the site, not to the syntax. Two tildes around text, ~~gone~~, produce <del>gone</del>.

Raw HTML

Markdown permits inline and block HTML. A block of HTML starts on its own line with a recognised tag and runs until the end condition for that type, which for most tags is a blank line. The rule that confuses people is that Markdown is not processed inside an HTML block. We ran two documents:

<div>
*not emphasised*
</div>

<div>

*emphasised*

</div>

The first <div> left the asterisks alone, because the block continued until a blank line. In the second, the blank lines ended the HTML block, so the text was parsed as Markdown and produced <em>emphasised</em>. This is the standard trick for wrapping Markdown in a <div> or <details> element. Always leave blank lines around the Markdown content.

Escaping

A backslash before a punctuation character makes it literal. \*literal\* produced *literal* with no <em>. Characters you may need to escape include backslash, backtick, asterisk, underscore, braces, brackets, parentheses, hash, plus, hyphen, dot and exclamation mark, but only where they would otherwise be read as syntax. Escaping a character that has no special meaning is harmless, since the backslash is only removed when it precedes ASCII punctuation. To show a literal ampersand or angle bracket, you can write them as is. The parser escapes & and < in text for you, so Hello, World! & more came out with &amp;.

How a Markdown parser works

Understanding the parser pays off whenever the output surprises you. CommonMark describes parsing as two phases: first the block structure, then the inline content.

Phase one: blocks

The parser reads the document line by line and decides which block each line belongs to: a paragraph, heading, list item, blockquote, code block, HTML block, table or thematic break. Containers such as blockquotes and lists can hold other blocks, so the result is a tree. A line that begins with > opens or continues a blockquote, and what remains of the line is processed again as if the > were not there. This is why a list inside a quote inside a list works without special cases.

At this stage the text inside paragraphs and headings is left as raw strings. The parser knows there is a paragraph containing Hello *world*., but it has not looked at the asterisks.

You can see the result of this phase by asking marked for its token stream instead of HTML. For the source # Guide, a blank line, Hello *world*., a blank line, and a two-item list, the block tokens were a heading, a paragraph, and a list with two items (with space tokens for the blank lines). The paragraph's own tokens, which come from the second phase, were text, em and text.

Phase two: inlines

Once the block tree exists, the parser walks every paragraph and heading and parses its text for inline constructs: emphasis, code spans, links, images, autolinks, raw inline HTML and hard breaks. This is the harder phase. Emphasis uses delimiter runs and a matching algorithm rather than simple regular expressions, which is why *foo**bar**baz* resolves cleanly and why naive regex converters break on nested input.

Order matters. Code spans bind more tightly than emphasis, so backticks protect their content: an asterisk inside `a*b` is never emphasis. Links bind more tightly than emphasis too, so [*text*](url) yields a link containing emphasis.

Phase three: rendering

The final step walks the tree and writes HTML. Each node type maps to an element: paragraph to <p>, emphasis to <em>, a fenced block to <pre><code>. Text is escaped on the way out, which covers <, >, & and quotes. Rendering is a separate step from parsing, which is why most libraries let you override how a given node type is turned into HTML, and why you can render the same tree to other targets such as PDF or plain text.

Why this design matters in practice

Three practical consequences follow.

  1. Block rules are decided before inline rules. A line that looks like a list marker is a list even if it would make a nicer paragraph. 2024. A great year at the start of a line is a list item, because the block phase sees a number and a period. Escape the dot as 2024\. to prevent it.
  2. Inline syntax cannot cross block boundaries. Emphasis that starts in one paragraph does not continue into the next. An unclosed * is just an asterisk.
  3. Bugs are local. An unclosed code fence swallows the rest of the document as code, since the block phase sees no end to it. A single broken line rarely wrecks everything else.

Flavors and extensions

"Markdown" in practice means one of several dialects. The main ones are below.

Flavor Basis Adds Notes
Original Markdown Gruber's script and prose Nothing beyond the core Ambiguous in many places
CommonMark Written specification with examples Nothing; precise core only The baseline most parsers target
GitHub Flavored Markdown CommonMark superset Tables, task lists, strikethrough, extended autolinks Specified at github.github.com/gfm
MultiMarkdown Extends the original syntax Footnotes, citations, metadata, tables and more Aimed at longer documents and publishing
MDX Markdown plus JSX Embedded components and expressions Compiles to JavaScript, not just HTML

Pandoc deserves a mention too: it reads many flavours and converts between dozens of formats, and its own Markdown variant has a long list of optional extensions.

MDX at a high level

MDX lets you write JSX components inside Markdown, so a documentation page can embed an interactive chart next to a paragraph. It is popular in documentation sites built with React-based tooling. The key conceptual difference is that MDX output is a JavaScript module, not a static HTML string, and the content is executable code. That has security implications: you must never compile MDX from untrusted authors, because it can run arbitrary JavaScript.

How to choose

If you control both the writing and the rendering, pick one flavour, document it, and test against it. If your content is published on several platforms, stick to CommonMark features plus tables and task lists, and check each target. If you accept Markdown from users, choose a parser that follows CommonMark strictly and add sanitisation, because the spec gives you predictable output but does not make it safe.

Rendering Markdown to HTML in practice

Rendering is the bit most developers do in code. Here are working examples, each run in the sandbox for this article.

JavaScript with marked

const { marked } = require("marked");

const src = "# Guide\n\nHello *world*.\n\n- a\n- b\n";
console.log(marked.parse(src));

Output:

<h1>Guide</h1>
<p>Hello <em>world</em>.</p>
<ul>
<li>a</li>
<li>b</li>
</ul>

This is the library the DevUtilX site itself depends on. A detail worth knowing: modern versions of marked do not add id attributes to headings by default, so you will need your own code to produce anchors. We show that below.

Most real projects customise the renderer. The next example gives each heading a unique slug, drops links whose scheme is not in an allow-list, and turns raw HTML into visible text. It uses the renderer API of marked 18.1.3, and the renderer method signatures have changed between major versions, so check the documentation for the version you install.

const { marked } = require("marked");

const slug = (s) =>
  s.toLowerCase().replace(/<[^>]+>/g, "")
   .replace(/[^\p{L}\p{N}\s-]/gu, "").trim().replace(/\s+/g, "-");
const seen = {};

marked.use({
  renderer: {
    heading({ tokens, depth }) {
      const text = this.parser.parseInline(tokens);
      let id = slug(text);
      seen[id] = (seen[id] || 0) + 1;
      if (seen[id] > 1) id += "-" + (seen[id] - 1);
      return `<h${depth} id="${id}">${text}</h${depth}>\n`;
    },
    link({ href, tokens }) {
      const text = this.parser.parseInline(tokens);
      if (!/^(https?:|mailto:|\/|#)/i.test(href)) return text;
      return `<a href="${href}" rel="noopener">${text}</a>`;
    },
    html({ text }) {
      return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
    },
  },
});

console.log(marked.parse(
  "## Setup\n\n## Setup\n\n## Café & Tea\n\n" +
  "[bad](javascript:alert(1)) [ok](https://example.com)\n\n<script>alert(1)</script>\n"
));

Output from the run:

<h2 id="setup">Setup</h2>
<h2 id="setup-1">Setup</h2>
<h2 id="café-amp-tea">Café &amp; Tea</h2>
<p>bad <a href="https://example.com" rel="noopener">ok</a></p>
&lt;script&gt;alert(1)&lt;/script&gt;

Read the output closely, because it teaches two lessons. First, the duplicate heading received the suffix -1, so both anchors are unique. Second, the third heading shows a bug in the sample: the id came out as café-amp-tea. The slug function ran on already-escaped HTML, where & had become &amp;, so the letters amp leaked into the identifier. The fix is to build the slug from the raw heading text rather than the rendered HTML. This is a good reminder that you must test slugs with punctuation and non-ASCII characters before publishing anchors that other pages link to.

The same output also shows that the javascript: link was reduced to plain text and the script tag became visible text. The allow-list approach here is a teaching example and not a complete defence. See the security section for the recommended approach.

Python

The sandbox for this article did not have a Markdown library installed, so rather than invent output, here is a runnable Python snippet for a task you will need whatever library you use: splitting front matter from the body and building slugs.

import re

doc = """---
title: Hello
tags: [a, b]
---
# Body
"""
m = re.match(r"\A---\r?\n(.*?)\r?\n---\r?\n(.*)\Z", doc, re.S)
print(repr(m.group(1)), repr(m.group(2)))

def slug(s):
    s = re.sub(r"[^\w\s-]", "", s.lower()).strip()
    return re.sub(r"\s+", "-", s)

print(slug("Café & Tea, Please!"), slug("What is Markdown?"))

It printed 'title: Hello\ntags: [a, b]' '# Body\n' and then café-tea-please what-is-markdown. In Python, the most widely used converters are third-party packages, such as the markdown package and mistune, and CommonMark-compliant bindings also exist. Check which spec each one follows, because Python-Markdown in its default configuration is closer to the original syntax than to CommonMark.

Command line

If you have Pandoc installed, pandoc -f commonmark -t html input.md converts a file using the CommonMark reader. Pandoc and similar tools also let you choose GFM as the input format. Naming the input format explicitly is a good habit, because it makes your build independent of the tool's default.

Measured sizes

Markdown is shorter than the HTML it produces, but not by much. As an experiment, we rendered the Markdown source of another article on this site, the JSON minification guide, with marked. The source was 38,051 bytes and the HTML was 43,941 bytes, about 15 percent larger. After gzip, the Markdown compressed to 14,980 bytes and the HTML to 15,609, a difference of about 4 percent. As with minified JSON, compression erases most of the overhead, because tags repeat. Rendering the 38 KB file took about 3.6 milliseconds per parse on average over 50 runs on the sandbox machine. Treat that as a rough figure for one machine and one library, not a benchmark, but it supports the common practice of rendering at build time and caching the result rather than worrying about parse cost.

Heading IDs and anchors

Linking to a section needs an id on the heading. Many platforms generate them automatically; many parsers do not. A good id scheme follows a few rules.

  • Lowercase, hyphen-separated, ASCII-friendly where you can. Keep non-ASCII letters if your audience reads that language, and test the links.
  • Unique within the page. Append a counter to duplicates, as in the example above.
  • Stable. If you rename a heading, the anchor changes and old links break. For long-lived documents, consider an explicit id syntax supported by your tool, or keep a redirect.
  • Built from text, not markup. Strip inline code, emphasis and escaped entities before slugging, as the bug above showed.

Different sites use different slug rules, so a link such as #caf-tea that works on one platform may fail on another. Do not assume a generated anchor in one tool matches another's.

Anchors also make a table of contents possible. Walk the token stream, collect headings with their depth and generated id, and render a nested list. Doing it from tokens is more reliable than scraping the final HTML.

Syntax highlighting

CommonMark only records the language in the info string, as class="language-js". Colouring is a separate step, and there are two usual designs.

Client-side. Load a highlighter script, which finds code[class^="language-"] elements after the page loads and wraps tokens in spans. It is easy, but it costs JavaScript, causes a flash of unstyled code and fails if scripts are blocked.

Build-time. Run the highlighter during rendering and emit pre-coloured HTML. The page needs only CSS, loads faster and works without JavaScript. This is the better default for documentation and blogs.

Either way, remember that highlighters add many <span> elements. Colour alone should not carry meaning, and your theme needs enough contrast against the code background to be readable. Test your dark theme as well as the light one. Wide lines should scroll horizontally within the <pre> block rather than breaking the layout, which a single overflow-x: auto declaration handles.

Front matter conventions

Many Markdown-based systems begin a file with a block of metadata between two lines of three hyphens. This is called front matter, and it is typically written in YAML:

---
title: Hello
tags: [a, b]
---
# Body

Front matter is not part of Markdown or CommonMark. A plain CommonMark parser sees the opening --- as a thematic break, then reads the metadata as a paragraph, and the closing --- turns the previous line into a Setext heading. If you feed such a file to a parser without stripping the block first, you get garbage at the top of the page. Tools that support front matter remove and parse it before the Markdown parser sees the body, which is exactly what the Python snippet above does.

Practical rules:

  • Put front matter at the very first byte of the file. A leading blank line or a byte order mark can stop it being recognised.
  • Quote YAML strings that contain a colon, a leading # or other special characters, or the YAML parser will fail or misread them.
  • Keep it to metadata: title, date, tags, description. Content belongs in the body.
  • Never parse untrusted YAML with a loader that can construct arbitrary objects. Use the safe loader of your YAML library.

The DevUtilX converter treats the text you give it as Markdown, so remove any front matter block first if you only want the body converted.

Real-world use cases

Project documentation. README files, contributing guides and changelogs are almost always Markdown, because they live next to the code, review well in pull requests and render on code hosting sites.

Static sites and blogs. Static site generators read a folder of Markdown files with front matter and produce HTML pages. Writers work in plain text and never touch markup, and version control gives a full history of every edit. The same arrangement powers this blog.

Documentation portals. Technical writers keep content in Markdown, and a build step turns it into a searchable site, often with MDX for interactive examples.

Comments, issues and chat. Many collaboration tools accept a Markdown subset in user input. This is the highest-risk use case, since the author is untrusted, and it needs sanitisation.

Email and newsletters. Writers draft in Markdown, convert to HTML, and paste the result into an email builder. Email clients have patchy CSS support, so check the output in the real client.

Notes and knowledge bases. Plain-text notes remain readable in thirty years, whatever happens to the app.

Generating content programmatically. Reports, release notes and chatbot responses are often assembled as Markdown strings, since it is easier to generate than HTML and easier to read when debugging.

Converting for a CMS. When a CMS expects HTML in a rich-text field, you can write in Markdown and convert before pasting. Review the markup afterwards using our guide to HTML formatting and semantic markup.

Security: never inject untrusted Markdown unsanitised

This is the most important section for anyone who accepts content from users.

Markdown is not a safe subset of HTML. The converter's job is to produce HTML, and a conforming CommonMark parser will pass through raw HTML and will produce links with whatever scheme the author typed. Our sandbox run showed it:

[a](javascript:alert(1)) and <script>alert(1)</script>

<img src=x onerror=alert(1)>

With the default marked configuration, the output was:

<p><a href="javascript:alert(1)">a</a> and <script>alert(1)</script></p>
<img src=x onerror=alert(1)>

Every dangerous piece survived: a javascript: link, a script element and an event-handler attribute. If you assign that string to innerHTML, or render it unescaped in a template, an attacker who controls the Markdown controls your page. This is cross-site scripting (XSS), and the OWASP XSS prevention cheat sheet explains the general approach to output encoding.

This is not a bug in the library. CommonMark says raw HTML is part of the language, and marked documents that it does not sanitise its output. The responsibility lies with you.

A layered defence

  1. Sanitise the rendered HTML with a dedicated library. Parse the output with an HTML sanitiser such as DOMPurify in the browser, or an equivalent server-side sanitiser, with an allow-list of tags and attributes. Sanitise after rendering, not before. Filtering Markdown source is unreliable because the same text can be reached through many syntaxes.
  2. Or disable raw HTML in the parser if your parser has such an option, or escape it as in the renderer example earlier. Do this when users have no legitimate need for HTML.
  3. Restrict URL schemes to http, https, mailto and relative paths for links and images. Remember that javascript:, data: and vbscript: URLs are dangerous in links, and that case and whitespace tricks are used to evade naive filters.
  4. Add rel="noopener" and consider rel="nofollow ugc" on user-submitted links, so they do not pass search ranking to spammers. Open external links in a new tab only if you have a reason.
  5. Deploy a Content Security Policy. A strict policy limits the damage of anything that slips through, by blocking inline scripts. The MDN documentation on CSP is the place to start.
  6. Treat images as network requests. An image from an arbitrary URL lets the author learn when and from where your page was viewed. Proxy images, or restrict hosts, if that matters to you.

A sanitiser should be the last step before the HTML reaches the page, and it should be configured by allow-list, not by trying to list everything dangerous.

Trusted versus untrusted content

Your own files in your own repository are a different case from comments typed by strangers. Rendering your own Markdown to HTML at build time carries little risk, although a contributor with write access could still add a script, so review pull requests that include raw HTML. For any content that crosses a trust boundary, sanitise it every time. MDX deserves special mention: it compiles to executable code, so accept it only from trusted authors.

Accessibility of generated HTML

Markdown gives you good semantic structure for free, and a few habits keep it accessible.

Alt text. The text in ![...] becomes the alt attribute, and screen readers announce it. Describe what the image conveys in context. A chart might need its main finding, a decorative image needs an empty alt, written as ![](image.png), so that assistive tools skip it. Never use the file name or write "image of". The W3C Web Accessibility Initiative has a good tutorial on images that covers the decision between informative and decorative images.

Heading order. Screen reader users navigate by headings, so the hierarchy should be logical: one level-one heading, then level twos, then level threes beneath them, with no skipped levels. A common mistake is choosing ### because it looks smaller. Choose the level for structure and use CSS for size. On a page where the template already provides an <h1>, start the Markdown body at ##, which is how this blog is organised. The MDN reference on heading elements explains the reasoning.

Link text. Write links that make sense out of context. "Read the CommonMark spec" beats "click here". Screen readers can list all links on a page, and a list of identical "here" entries is useless.

Tables. GFM tables always have a header row, which becomes <th> cells inside <thead>. That is good: assistive technology uses those cells to announce column names. Keep tables simple, since Markdown has no way to express merged cells, row headers or captions. If your data needs them, use an HTML table, and see our HTML formatting guide for the markup. Do not use tables for layout.

Lists. Use real list syntax for lists, not a paragraph of hyphens or manually typed numbers, so assistive technology can announce "list, three items".

Task list checkboxes. The generated inputs are disabled and have no associated label element. The adjacent text is visible but not programmatically linked to the input, so the checkbox may be announced without its label by some screen readers. If the list is interactive in your product, build proper labelled controls instead of relying on the default output.

Code blocks. Long code needs a horizontal scroll area that can receive keyboard focus, and syntax colours must keep sufficient contrast. Avoid conveying meaning through colour alone.

Emphasis. Use * and ** for real emphasis and importance, not for decoration, since they map to <em> and <strong>, which some assistive technology may announce differently.

Language and reading level. Short paragraphs and plain words help everyone, and Markdown makes short paragraphs the easy path.

Common mistakes and pitfalls

  • Forgetting the blank line before a list or code block in portable content. CommonMark often tolerates it, but older parsers do not. A blank line is cheap insurance.
  • Missing the space after the marker. #Title, -item and 1.item are paragraphs, not headings or lists.
  • Mixing tabs and spaces in nested lists. Tab width handling varies, so use spaces and one consistent indent.
  • Changing bullet characters mid-list. Each change starts a new list, as we saw with *, + and -.
  • Relying on trailing spaces for line breaks. Editors strip them. Use the backslash form or a blank line.
  • Unescaped special characters. An underscore or asterisk in a filename or maths expression can trigger emphasis. Wrap such text in backticks or escape it.
  • Starting a line with a number and a period. 2024. A great year becomes a list. Escape as 2024\..
  • Markdown inside HTML blocks. It is not processed unless blank lines separate the HTML tags from the content.
  • An unclosed code fence. Everything after it becomes code. A fence can be closed only by a fence at least as long as the opening one, so use four backticks to show a three-backtick fence.
  • Assuming extensions work everywhere. Tables, task lists, footnotes and strikethrough are not in the CommonMark core.
  • Putting front matter into a converter that does not understand it. You will get a stray rule and heading.
  • Inserting rendered output as trusted HTML. See the security section. This is the mistake with the biggest consequences.
  • Skipping heading levels or using headings for size. It hurts navigation and accessibility.
  • Treating Markdown as a layout tool. If you find yourself fighting for exact spacing or columns, move that part into HTML and CSS.
  • Editing generated HTML by hand. The next conversion overwrites it. Fix the Markdown source instead.

Step-by-step: convert Markdown to HTML with DevUtilX

For a quick conversion, with no installation, use the DevUtilX Markdown to HTML converter. It runs in your browser.

  1. Open the Markdown to HTML tool.
  2. Paste or type your Markdown into the input editor. If the file starts with front matter, remove that block first.
  3. Run the conversion and read the generated HTML in the output panel.
  4. Check structure first: headings in the right order, lists nested as you intended, tables with header cells and code blocks with the right language class.
  5. Copy the HTML to your CMS, email builder or template.

Your text is processed locally in the browser, and nothing is uploaded, which is helpful when the document is an unpublished draft, an internal runbook or a customer-facing message. Even so, avoid pasting passwords, tokens or personal data into any tool.

Two notes on the output. First, the converter produces markup, not a styled page, so you will see the HTML elements but the look depends on the stylesheet of wherever you paste them. Second, if you plan to put the result on a web page that accepts content from other people, run it through a sanitiser first, as described above. When the HTML is going to ship to production, our HTML minification guide explains what a minifier may safely do to it, and you can use the comparison techniques from our article on comparing files and code diffs to check what changed between two conversions.

Troubleshooting guide

Work from symptom to cause to fix.

Symptom: my list renders as one paragraph

Cause. The marker has no space after it, or an older parser needs a blank line between the paragraph and the list, or the lines are indented four spaces and became a code block.
Fix. Write - item with a space, add a blank line before the list, and remove excess indentation at the top level.

Symptom: nested list items appear at the same level

Cause. The nested marker is not indented enough to line up with the parent item's content.
Fix. Indent the child so its marker sits under the first character of the parent's text. For - parent, two spaces suffice, and for 1. parent, use three.

Symptom: a table shows as plain text with pipes

Cause. The renderer does not support the GFM table extension, or the delimiter row is missing, or the header and delimiter rows have different numbers of columns.
Fix. Enable GFM or choose a tool that supports it. Add a delimiter row such as |---|---|, and make the column counts match.

Symptom: half a sentence is italic

Cause. An unescaped underscore or asterisk paired with another one later in the paragraph, for instance in *args and **kwargs or in a path.
Fix. Put the technical text in backticks, or escape the characters with a backslash.

Symptom: Markdown inside a div is not rendered

Cause. The content sits inside an HTML block, where Markdown processing is off.
Fix. Add a blank line after the opening tag and before the closing tag, as in the example earlier.

Symptom: the page after a code block is all code

Cause. The fence is not closed, or the closing fence is shorter than the opening one.
Fix. Add the closing fence on its own line. When the content includes backtick fences, wrap the block in four backticks or use tildes.

Symptom: line breaks disappeared

Cause. Single newlines are soft breaks, and trailing spaces were stripped by the editor.
Fix. End the line with a backslash, or use a paragraph break. If you want every newline to be a break, enable the breaks option in your parser and accept that your output now differs from strict CommonMark.

Cause. The converter did not generate ids, or generated them with different rules than the link assumes, or a duplicate heading was given a suffix.
Fix. Inspect the generated HTML, add an id generator and link to the actual value. Keep links in one document consistent with the tool that built the page.

Symptom: raw HTML disappears from the output

Cause. The renderer is configured to escape or strip HTML, or a sanitiser removed the tags.
Fix. If you control the content, allow the tags you need in the sanitiser's allow-list. If users supply the content, this is the intended behaviour.

Symptom: special characters appear as odd symbols

Cause. Character encoding mismatch between the file, the converter and the page, usually UTF-8 against something else.
Fix. Save as UTF-8, and declare <meta charset="utf-8"> on the HTML page.

Symptom: the output differs between my editor preview and the live site

Cause. The preview and the site use different parsers or flavours, or the site applies a sanitiser.
Fix. Standardise on one flavour, run the same renderer in your build and your preview, and add a test that renders sample documents and compares the output.

Performance considerations

Rendering Markdown is cheap, as the measurement above suggests, so the main performance decisions are architectural.

  • Render at build time where you can. Pre-rendered HTML costs nothing at request time. Reserve runtime rendering for content that changes per user.
  • Cache rendered output keyed on a hash of the source and the renderer version. Invalidate when you upgrade the parser, since output can change.
  • Avoid shipping a parser to the browser unless you need live preview. A large renderer plus a sanitiser adds JavaScript weight for a page that could be static.
  • Beware of pathological input. Very deeply nested quotes or lists and enormous documents can cost a lot of time or memory in some parsers. For user-supplied Markdown, cap input size and run rendering with time and memory limits.
  • Lazy-load images with an attribute added by your renderer or a post-processing step, and give them dimensions to avoid layout shift. Plain Markdown cannot express either.
  • Minify the final HTML only if it matters, and measure first, as covered in our HTML minification guide. Compression removes most of the markup overhead anyway.

Best practices and checklist

  • Pick one flavour and write it down in the project's contributing guide.
  • Use ATX headings and a single # level-one title per page, with no skipped levels.
  • Use fenced code blocks with a language tag for every sample.
  • Prefer reference links for long URLs, and descriptive link text always.
  • Write meaningful alt text for informative images, and empty alt for decorative ones.
  • Leave blank lines around lists, code blocks, tables and HTML blocks.
  • Keep one sentence per line, or one paragraph per line, consistently, so version control diffs stay readable.
  • Use spaces, not tabs, with a consistent indent.
  • Avoid raw HTML unless you need what Markdown cannot do.
  • Sanitise any untrusted content after rendering, with an allow-list, and add a Content Security Policy.
  • Strip or parse front matter before the Markdown parser.
  • Generate unique, stable heading ids, and test them with punctuation and non-ASCII text.
  • Test your renderer with a fixed set of sample documents, and review the diff when you upgrade the library.
  • Lint your Markdown in CI for consistent style and broken links.

Before publishing, run through a short checklist:

  1. Does the document render identically in the preview and in production?
  2. Are headings in order and ids unique?
  3. Do all images have appropriate alt text?
  4. Do all links work and use allowed schemes?
  5. Is the HTML sanitised if any part of the source is untrusted?
  6. Are tables and code blocks readable on a narrow screen?
  7. Has front matter been removed from the body?

Comparison: Markdown and its alternatives

Format Strengths Weaknesses Best for
Markdown (CommonMark/GFM) Readable source, huge tooling, easy to learn Limited features, flavours differ, raw HTML risk Docs, READMEs, blogs, notes
HTML Full control and semantics Verbose and slow to write by hand Complex layout, forms, final output
reStructuredText Rich directives, cross-references, strong in Python docs Steeper learning curve, smaller ecosystem Large technical documentation sets
AsciiDoc Powerful book-length features, tables, includes Fewer tools than Markdown Books, manuals, structured publishing
Rich-text editors (WYSIWYG) No syntax to learn Messy markup, hard to version-control Non-technical editors
Wiki markup Familiar on wiki platforms Varies by platform, little reuse elsewhere Collaborative wikis
MDX Components inside content Executable code, needs a build step Interactive documentation

Markdown wins when portability and simplicity matter more than expressiveness. If you routinely need cross-references, includes, admonitions and indexes, a richer format such as reStructuredText or AsciiDoc may pay for its learning curve. If a page is mostly layout and interaction, write HTML. For related reading on structured markup formats, see our guide to XML formatting and structure, which shows how a stricter language handles the same trade-offs.

FAQ

Is Markdown the same as HTML?
No. Markdown is a shorthand for writing documents that a converter turns into HTML. It covers the common elements, such as headings, paragraphs, lists, links, images, code and quotes, and lets you embed raw HTML for anything else.

What is the difference between Markdown and CommonMark?
CommonMark is a precise specification of Markdown, with a test suite, that settles the ambiguities in Gruber's original description. If a parser follows CommonMark, the same core input gives the same output across tools.

What is GitHub Flavored Markdown?
GFM is a superset of CommonMark that adds tables, task lists, strikethrough and extended autolinks. It is formally specified at github.github.com/gfm.

Why does my Markdown look different on different sites?
The sites use different parsers or flavours, enable different extensions, or apply their own sanitising and styling. Check which flavour each target supports and keep to the shared core.

Is Markdown safe to render on a website?
Not by itself. Raw HTML and javascript: links pass through a standard parser. For anything from untrusted authors, sanitise the rendered HTML with an allow-list sanitiser and add a Content Security Policy.

How do I add a line break without starting a new paragraph?
End the line with a backslash or with two trailing spaces. The backslash is more reliable because editors often strip trailing spaces.

How do I get a table in Markdown?
Use the GFM table syntax with pipes and a delimiter row of hyphens. It is an extension, so make sure your renderer supports it.

How do I show Markdown syntax literally in a document?
Put it in a code span or a fenced block. To display a fence itself, surround it with a longer fence, such as four backticks, or use tildes.

Can I put Markdown inside an HTML element?
Yes, if you leave blank lines between the HTML tags and the Markdown content. Without blank lines, the content is treated as part of the HTML block and is not processed.

Does Markdown support colours, fonts or exact layout?
No. Those belong in CSS applied to the generated HTML. If you need them in the source, use raw HTML, with the security caveats above.

Where does front matter come from?
It is a convention of static site generators and similar tools, not part of Markdown. Strip it before sending the body to a plain Markdown parser.

Should I use underscores or asterisks for emphasis?
Either works for whole words. Asterisks also work inside words, while underscores do not in CommonMark, so asterisks are the safer default.

How do I make heading links work?
You need an id on each heading. Some tools generate them, while others, including a default marked setup, do not. Generate unique, stable slugs and link to those.

Is it safe to paste Markdown into an online converter?
Only if the tool runs in your browser. DevUtilX processes your input locally, but you should still avoid pasting secrets or personal data into any tool.

Can I convert HTML back to Markdown?
Yes, with tools designed for the reverse direction, but the round trip is lossy. Markdown cannot express every HTML construct, so complex markup falls back to raw HTML or loses attributes.

Conclusion

Markdown succeeded because it let people write plain text and publish HTML, and CommonMark made that promise reliable by turning a loose description into a specification with examples. Learn the core syntax and its few sharp edges, such as list indentation, blank lines and HTML blocks, so you can predict the output. Remember the two-phase parser, block structure first and inline content second, whenever something renders strangely. And treat rendered Markdown as untrusted HTML whenever the author is not you: sanitise it, restrict URL schemes and add a Content Security Policy.

To convert a document right now, open the Markdown to HTML converter, paste your text and review the result. To continue, read our guides on HTML formatting and semantic markup, which shows what good generated markup looks like, and on comparing files and code diffs, which helps you review changes when you switch parsers or upgrade a renderer.

Further reading

Try the tools

Further reading

Related articles