Markdown to HTML - Every Block Named, Every Refusal Recorded
CommonMark block structure resolved by column arithmetic, emphasis by the rule of three, and four tables underneath: which block each run of lines became and why, which constructs were declined rather than interpreted, and your own browser's HTML parser checking the markup that came out. Nothing you type leaves this device.
Every block, in the order the first pass found it
| Lines | What it became | Why the first pass saw it that way |
|---|
The inside of those blocks, counted
| Construct | This run | What the number covers |
|---|
Declined to interpret
| Why it stayed text | The piece it stopped on | Times |
|---|
Read back with your browser's own HTML parser
| Check | The converter | Your browser | Agree? |
|---|
Block structure and the emphasis rule
Two passes, neither one a find-and-replace
Two passes, and neither is a search-and-replace. The first reads the document as a stack of open containers and decides, line by line, what block each run of lines is: it counts columns in tab stops of four, allows three spaces in front of anything that starts a block, and asks in a fixed order whether the line is the tail of something already open, a fence, one of the seven kinds of HTML block, a rule, an ATX heading, a list marker, a quote marker, or four columns of indentation. Only once a run of lines has been closed does the second pass read the inside of it, where a backslash run of the exact length opens and closes a code span, an ampersand sequence either resolves to a character or stays exactly as typed, angle brackets either spell an autolink or a tag or neither, and a bracket pair looks for its destination in three forms and takes the first that matches.
That order is not decoration; it is why some things the converter does not do are not omissions. A line whose fourth column holds content is code and nothing else, so the *emphasis* inside it stays two asterisks. A list marker is only a list marker if what follows it can be a paragraph, which is why - with nothing after it is a list with one empty item and - at the end of a paragraph is a dash and a bullet. A block quote takes one space or one tab after its marker and no more, so the fifth column inside a quote is indented code rather than text - the arithmetic that decides this is the same arithmetic the tables below print, and it runs on your document, not on an example.
Every number in those tables came out of the run you just made. The block ledger gives one row per block with the lines it covers and the rule that claimed it; the inline counters count the links, spans, autolinks, references and delimiter runs the second pass met; the refusal ledger lists the pieces that were looked at and decided against, with the text each one stopped on and how many times; and the last table is the markup the converter emitted, handed back to your browser’s HTML parser to be counted again.
What the block ledger is telling you
The line ranges are ranges of your document, at every depth. A list item three containers down is printed with the line numbers it has in the text you wrote, not relative to the quote or item it sits in, because the only use of a ledger row is to let you find the block it describes. A container is printed before the block inside it, and a block that covers more lines is always printed before the one it contains.
The reason column is the part worth reading, because the two most common surprises in markdown are both decided there. The first is looseness: a list is loose when a blank line sits between its own items at its own level, and tight otherwise, and a tight list renders its items as bare text while a loose one wraps each in a paragraph. A blank line inside a nested list does not make the outer list loose, and that is a rule about the level the blank was measured at, not a quirk of the converter. The second is a block quote’s lazy line: a line with no > on it can still belong to a paragraph the quote already opened, and it cannot be anything else - it is text, which is why it can never become a setext underline, and the row says how many such lines the quote swallowed.
Why *foo* bar * is not what people expect
Emphasis is decided by the characters on either side of each delimiter run and by the length of the run as it was typed, before anything was consumed. A run that has whitespace on the outside and punctuation on the inside can open but not close, and the other way round closes but does not open; _ adds a further restriction, because it will not act as emphasis when it sits inside a word, which is what keeps snake_case_name a variable rather than a formatting disaster. When a candidate closer and a candidate opener meet, the two run lengths are added and if the sum is a multiple of three the pair is refused - unless both runs are themselves multiples of three.
That rule of three is applied to the lengths as typed, which is the part a page written from memory usually gets wrong: in *foo**bar* the inner ** has already been shortened to * by the time the outer pair is considered, and if the arithmetic uses the shortened length the whole expression comes out as nested emphasis instead of a stray asterisk. The same discipline is why a link cannot contain a link: when a bracket pair becomes a link, every opener before it is spent, and a ] that finds only spent openers is a character, not a closer.
Handing the markup back to the browser’s parser
Counting the blocks it made would only be the converter agreeing with itself, so the last table asks somebody else. The output is handed to your browser’s own HTML parser - the same parser that will read this HTML wherever you paste it - and the two element trees are compared position by position: what the converter thinks it placed, against what the parser says it found. A parser written by a different team, to a different specification, over twenty years, is a second implementation of HTML, and it is the only outside party here allowed to decide what a document made of these tags actually is.
Two things about that comparison are worth knowing before you read the verdict row.
It is always made against the escaped pass, whatever the switch above is set to. In that pass every element in the tree was placed by the converter, because your own markup arrived as text; in the other one your tags are in the output too, and a disagreement there would be evidence about your markup rather than about this converter. So the row under the verdict says plainly that markup you wrote was left out of both sides.
It is a list of elements and where they sit, nothing else. It compares tag names and nesting, not attributes and not text, so it cannot tell you the href of a link came out right or that a word survived; those are what the block ledger, the counters and your own eyes are for. When the two trees differ, the table prints the position at which they stopped matching and what each side had there, and nothing on one of them means that side ran out of elements first. If your browser offers no HTML parser to scripts, the row says did not run rather than yes, because a check that never happened has no opinion.
The limits, stated
- Checked against the specification’s own examples, and matching all 652 of them. That number is not a claim of correctness, only of agreement, and one of the examples is worth naming because it is the one people report as a bug: HTML block example 148 asks for a paragraph that closes after the
</pre>line it absorbed, which prints</pre></p>- markup no browser can un-nest and no author intended. The converter produces it, because the rule underneath is the one the spec states: a line that is a closing</pre>-form tag belongs to the raw-text rule, not the block rule, so it never interrupts the paragraph above it. Both of the widely-used implementations checked against here print the same, and a converter that “fixed” it would be the odd one out. - It is CommonMark, not GitHub’s markdown. Tables, task list checkboxes, strikethrough and bare URLs turned into links automatically are all outside the spec the converter follows, so they arrive as the text you wrote: a
| a | b |row is a paragraph with pipes in it, and it will say so in the ledger. - Named character references are answered by your browser. There are about 2,231 of them, several share a spelling with a character nobody meant, and a table copied into a converter drifts from whatever reads the output in the end. So
§is resolved by the parser that will render the output, and a name that parser does not know is left exactly as typed - which is also what the spec says to do with©without its semicolon. - Three windows bound the work. A tag-shaped run of text is scanned for a closing angle bracket within 4,096 characters of where it opened; container nesting stops at 99 levels deep, and past that another
>or another list marker is recorded as a refusal and stays literal; the ledger prints 400 rows and then says how many it left off. The box itself stops at 150,000 characters, and it refuses the whole document rather than converting a prefix of it, because a truncated output would still look finished. - Raw markup is your risk, not the converter’s. In the default mode HTML in your text is passed through as markup, so your browser decides what those tags become. The preview frame has scripts switched off and cannot run them; the file you save has no such protection. If the text did not come from you, switch the mode first.
- One implementation, one set of tables. Everything here is a single converter written to one version of one specification, checked against its published examples and against two other implementations where they were available. Agreement between them is a strong signal and it is not a proof; the numbers here describe what the converter did with your text, and the read-back is the one place where an outside party got to check.