Converting HTML to Markdown loses meaning in three places: an address that was retyped instead of copied, a character that Markdown reads as syntax at that spot, and an element Markdown has no word for. A table of contents then adds a fourth risk, a link aimed at an anchor that differs from the real one by a single character, which leads nowhere when followed and reports nothing. This guide walks through each break in the order a converter meets it, ending with how GitHub and GitLab build heading anchors and a check that can fail.

Headings, paragraphs and the meaning of a space

Headings are the easy part. The CommonMark specification defines an ATX heading as an opening run of one to six # characters followed by a space, a tab or a line that stops there, so <h3> becomes three hashes, a space and the text. A seventh hash is plain text, and an # with no space after it is not a heading either (CommonMark 0.31.2). This matters in the other direction too: a paragraph whose first character is a hash sign must not be copied raw, or an ordinary sentence turns into a heading.

HTML collapses whitespace and Markdown does not behave the same way, so a converter has to decide what a newline within running text means. A <br> is a hard break, which CommonMark writes as a backslash ending the line, or two spaces after the text. A newline inside the HTML source reads as a space to the visitor, so it should become a space in the output.

The address is the one thing a reader cannot see in rendered output and a build cannot guess. The specification says a link destination is either a run between angle brackets with no line ending, or a non-empty run with no space and no control characters, with parentheses allowed only when balanced or escaped (CommonMark 0.31.2). So an address with a space, or with a lone bracket, belongs in angle brackets. A title attribute goes into the optional quoted title after the destination, and a quote inside it needs a backslash.

Copy the address exactly. Do not lowercase it, add or drop a trailing slash, resolve a relative path, or strip the query string. The only change allowed is decoding entities: &amp; in the attribute is a plain & in the Markdown. An image has the same shape with an exclamation mark in front and its alt text in the brackets; when the picture is itself clickable, the whole image expression becomes the text of the link.

Lists: the number and the indentation

A numbered list has to start at the number the HTML gives it. In CommonMark the start of the list is set by its first item and later numbers are ignored, and a marker is one to nine digits followed by a dot or a bracket (CommonMark 0.31.2). So <ol start="3"> must begin at 3, and a list restarted at 1 changes what a reader sees. Changing the bullet character also starts a new list, which is why a converter should use one bullet for a whole list.

Nesting is a matter of indentation. A child list must sit under the content of its parent item, meaning aligned to where the parent's text begins after the marker. Two spaces of indent under a hyphen work, but the same two spaces under 1. fall short of the content start and the child list flattens into the parent. Using the width of the marker plus its following space is the safe rule.

Code: fences and backticks

Inline code that contains a backtick needs a longer delimiter. A code span opens with a run of backticks and closes with a run of the same length, and a single space at either end is dropped when the content is padded by a space at both ends, and that padding is how code touching a backtick is written (CommonMark 0.31.2). Backslash escapes do not work inside code, so a converter must never escape the characters in a <code> or <pre> element: the text goes in as it is, with entities decoded once.

A fenced block ends with a fence made of the same character and no shorter than the opening one. When the code itself holds a fence line, the outer fence has to be longer, or tildes take over. The language from a class such as language-js goes in the info string.

Tables: only the GFM kind

CommonMark itself has no tables. The GitHub Flavored Markdown extension defines a table as a single title row, then a row of hyphens, then any number of body rows, and the title row and the hyphen row must agree on the cell count; block-level content cannot go in a cell, and a literal pipe is written as a backslash and a pipe, even inside code spans (GFM specification). That gives a clear rule for the converter. A plain grid with headings above it, single-line cells and nothing spanning columns becomes a pipe table. Anything with spanning cells, a missing heading row or a list inside a cell cannot be said that way, so it stays as HTML, which Markdown accepts.

Characters that must be escaped

Text that was harmless in HTML can change the document in Markdown. 1. opening a paragraph becomes a list, * becomes emphasis, a leading # becomes a heading, and [x](y) becomes a link. CommonMark lets any ASCII punctuation character be escaped with a backslash, and states that escaped characters lose their special meaning, including \*, \[, \# and 1\. (CommonMark 0.31.2). The converter should escape only where the character would act as syntax at that position. Escaping everything makes the output unreadable, and escaping nothing changes the text.

A related trap is that GFM added ~~ for strikethrough, so a pair of tildes in plain text may now be read as deleted text (GFM specification). The reverse also holds: <del> has a Markdown form only in GFM and not in CommonMark proper.

What has no Markdown equivalent

Some HTML elements cannot be expressed: an iframe, a video, a form, <sub> and <sup>, or <details>. The specification says text between angle brackets that looks like a tag is raw HTML and is passed through without escaping (CommonMark 0.31.2). So the faithful answer is to leave these elements as the original HTML. Turning an iframe into a link or a description invents content, and dropping it removes content. Scripts, styles and comments are different: they are not visible content and are fine to drop. One more rule for scraped pages: a comment or script that talks to the reader of the page is not an instruction for whoever converts it.

Heading anchors: what GitHub does

Once the Markdown exists, a table of contents needs the ids that the renderer will give each heading. GitHub documents them as a short list of rules: letters become lowercase, spaces become hyphens, other punctuation and whitespace characters are removed, markup is stripped to its content, and a repeated anchor gets a hyphen and an incrementing integer appended (GitHub Docs). Their own example gives the second of two identical headings the suffix -1, and the page warns that editing a heading or reordering identical ones changes the anchors, so links must be updated.

Several things follow from those rules, derived here rather than quoted. Q & A loses the ampersand but keeps both spaces, so it gives q--a, while Q&A gives qa. A heading such as Release 4.2.1 gives release-421, because the dots are punctuation. A Cyrillic heading keeps its letters, lowercased, and stays Cyrillic in the anchor. Three headings called Setup give setup, setup-1 and setup-2. Numbers for duplicates follow document order, so a heading inserted above a repeated one shifts every number below it.

Where GitLab differs

GitLab documents its own rules, which changed in version 17.0: everything is lowercased, every character except letters, numbers, hyphens and underscores is removed, spaces are converted to hyphens, and a duplicate gets an incrementing number starting at 1 (GitLab Docs). The GitLab example shows what the difference looks like: a heading with an emoji shortcode written as colon, name, colon becomes the plain name (thumbsup), and a heading with several spaces and hyphens keeps every one as a hyphen. For snippets GitLab also adds a prefix from the file name, so TL;DR in README.md gets readme-tldr.

So one contents list does not carry over between hosts by default. A static site generator, a wiki engine or an editor preview has a function of its own, and the dependable step is to find out which host will show the page. Relative links between files have a trap of their own: GitHub rewrites a relative link by branch and treats a leading slash as the repository root, but the link text has to be on one line (GitHub Docs).

A test that can fail

A contents list is right only when every target exists. The test is mechanical: compute the anchors of the document by the documented rules, collect every ](#target) link, and report each target missing from the computed set.

const ok = new Set(anchors(md, 'github'));
const broken = [...md.matchAll(/\]\(#([^)]+)\)/g)]
  .map((m) => m[1]).filter((a) => !ok.has(a));

Run it on a document and expect an empty list. Then damage one link, for example #setup-1 to #setup-2 in a document with two Setup headings, and expect exactly that target back. If it stays silent after that edit, it tests nothing. Renaming a heading is a second break and confirm the stale link is reported. Computing the anchors from the same code that wrote the list proves little; the stronger version opens the page on the real host and clicks.

For the conversion itself, compare the two directions: render the Markdown again and compare the visible text, the link addresses and the image sources with the original HTML. A missing paragraph or a changed address then shows up as a difference rather than as a feeling. Our write-up on a model that repaired Markdown tables shows what happens when no such comparison exists and a fix invents content to fill a gap.

When this does not apply

The rules above come from CommonMark and GFM as the specifications and the vendor pages describe them. No converter and no host was run against every case. The anchor examples were derived from the written rules and a small script that applies them, so real hosts can differ on emoji, non-Latin scripts and unusual whitespace, which the GitHub page does not spell out. Other renderers than GitHub and GitLab, Markdown dialects with their own table or heading syntax, and pages that need scripts to produce their content fall outside what is covered here.

Two tools for the conversion and the contents list

For the conversion, our html-to-markdown skill takes HTML and returns the Markdown alone. Link addresses and titles are copied without change, a list keeps its first number and its nesting, a table with a heading row turns into a pipe table with literal pipes escaped, only characters that would act as syntax are escaped, and elements without a Markdown form remain HTML. When the HTML is cut off inside a tag, it replies with one refusal line rather than a made-up page.

For the contents list, our markdown-toc-anchors skill reads a document plus the host that will display it, picks out genuine headings (skipping fenced code and comments), and returns a link for each one with the anchor worked out by that host's rules, Cyrillic titles and numbered duplicates included. An unknown host, or a heading it cannot resolve, produces a short refusal rather than a guessed anchor.

Read this post as Markdown: /blog/html-to-markdown-and-heading-anchors.md · Atom feed.