Two tools can both claim to support “Markdown” and still disagree about tables, task lists, bare URLs, and even the HTML element used for strikethrough.
That is usually not a parser bug. It is a contract problem.
“Markdown support” often collapses three separate layers into one label:
- a base syntax such as CommonMark;
- extensions such as GitHub Flavored Markdown;
- platform-specific configuration, sanitization, and post-processing.
I ran a small differential test to make those boundaries concrete.
Fixed environment
Tested on August 15, 2026:
-
commonmark0.31.2 -
markdown-it14.3.0, default preset, no third-party plugins -
marked18.0.7, default options
The comparison records generated HTML, not screenshots. That distinction matters: two results can look similar while producing different DOM structures for accessibility, export, or later transforms.
Minimal setup:
import * as commonmark from "commonmark";
import markdownit from "markdown-it";
import { marked } from "marked";
const source = "before ~~deleted~~ after";
const reader = new commonmark.Parser();
const writer = new commonmark.HtmlRenderer();
console.log(writer.render(reader.parse(source)));
console.log(markdownit().render(source));
console.log(marked.parse(source));
These are versioned defaults, not permanent labels for the libraries. Options and plugins can change the result.
1. A pipe table is not base CommonMark
Input:
| A | B |
|---|---|
| 1 | 2 |
Observed structure:
| Parser | Result |
|---|---|
| commonmark 0.31.2 | One paragraph; pipes remain text |
| markdown-it 14.3.0 | A table with thead and tbody
|
| marked 18.0.7 | A table with thead and tbody
|
CommonMark 0.31.2 does not define pipe tables. GFM adds them as an extension.
When I need a controlled table input for a compatibility test, I use a Markdown table generator to avoid accidental delimiter mistakes, then verify the output with the actual target renderer. Generating valid source is not the same as proving platform compatibility.
2. A pipe inside a code span is a hard case
Input:
| A | B |
|---|---|
| `x|y` | a\|b |
With the tested markdown-it and Marked defaults, the unescaped pipe inside the code span still splits the row. One cell receives the opening backtick plus x; the next receives y plus the closing backtick, instead of one code span.
GFM’s table examples require escaping that pipe even inside a code span:
| A | B |
|---|---|
| `x\|y` | a\|b |
This is why splitting a table row with source.split('|') is not a parser.
3. A table-like shape can fail correctly
Input:
| A | B |
| 1 | 2 |
All three parsers emitted a paragraph. Without the delimiter row, none guessed that the author intended a table.
For editor diagnostics, checking for a real table node is safer than checking whether the source contains pipes.
4. Strikethrough has two kinds of differences
For:
before ~~deleted~~ after
- commonmark keeps the tildes as text;
- markdown-it emits
<s>; - Marked emits
<del>.
For the boundary case:
before ~not deleted~ after
only Marked recognized strikethrough in this test.
There are two contracts here: whether the syntax is recognized, and which HTML structure represents it. Screenshot tests can miss the second one.
5. Task lists may be plain text or form controls
Input:
- [x] shipped
- [ ] pending
commonmark and the markdown-it default produced normal list items containing [x] and [ ]. Marked produced disabled checkbox inputs and marked the first one as checked.
Whether a host then makes those boxes interactive is a UI and security decision, not something the Markdown syntax alone can decide.
6. Angle-bracket autolinks and bare URLs are different features
All three parsers linked this CommonMark form:
<https://example.com/a_(b)>
But for:
Visit https://example.com/a_(b).
only Marked linked the bare URL under the tested defaults. It also excluded the trailing period from the destination.
markdown-it can add this behavior with linkify; that option was deliberately left off so the matrix describes the default preset.
7. Raw HTML policy is not HTML safety
Input:
<xmp>**not emphasis**</xmp>
commonmark and Marked preserved the xmp element. markdown-it escaped the tags because raw HTML is disabled by default. All three still parsed the emphasis inside.
GFM has a tag-filter extension for names such as script, style, iframe, and xmp. That filter is not a complete HTML sanitizer. Marked’s own documentation also warns that its output is not sanitized.
A production pipeline therefore needs two explicit answers:
- What HTML can the parser generate?
- What HTML does the sanitizer allow?
The resulting capability matrix
For these exact versions and defaults:
| Capability | commonmark | markdown-it | Marked |
|---|---|---|---|
| Angle-bracket autolink | Yes | Yes | Yes |
| Pipe tables | No | Yes | Yes |
~~x~~ strikethrough |
No | Yes | Yes |
~x~ strikethrough |
No | No | Yes |
| Task-list checkboxes | No | No | Yes |
| Bare-URL autolinking | No | No | Yes |
| Raw HTML enabled by default | Yes | No | Yes |
| Complete HTML sanitization | No | No | No |
“No” is not automatically a defect. Disabling raw HTML can be a deliberate safety boundary. markdown-it can gain more syntax through plugins. The important point is that version and configuration belong in the contract.
A more useful declaration
Instead of only storing:
{ "flavor": "gfm" }
an application could expose something closer to:
{
"base": "commonmark-0.31.2",
"extensions": {
"tables": true,
"strikethrough": "single-or-double-tilde",
"taskList": true,
"extendedAutolink": true,
"tagfilter": false
},
"rawHtml": "parse-then-sanitize"
}
That object is testable, versionable, and much harder to misunderstand than a dialect name.
The open question is whether Markdown ecosystems should standardize a machine-readable capability manifest—or whether every platform will continue documenting its dialect through examples and surprises.
Top comments (0)