From text to output

Markdown is written for people, but browsers do not understand it. Before anyone sees formatted text, a program has to convert it. That happens in two steps, even when a single tool does both.

A Markdown parser reads the plain text and decides what each part is: this line is a heading, these three lines form a list, this word is emphasized. It applies the rules of one Markdown flavor, for example CommonMark. The result is not text any more but a structure in memory.

That structure is an abstract syntax tree (AST): a tree in which the document is the root, blocks such as headings, paragraphs and lists are its branches, and the inline pieces (plain text, emphasis, links, code) are the leaves. "Abstract" means that the punctuation is gone: the tree records "strong emphasis", not whether you typed ** or __. A renderer then walks the tree and writes the output format. The parsing step is the same idea as parsing JSON: text in, structure out.

The conversion pipeline. A Markdown file goes into a parser. The parser produces a syntax tree. A renderer turns the tree into HTML, which a browser displays; other renderers produce Word or PDF from the same tree. runbook.md plain text parser syntax tree in memory renderer HTML to the browser or .docx, .pdf, slides, …
Parse once, render many ways: the same tree can become HTML, a Word document or a PDF.

Here is a short outage note and the HTML that markdown-it makes of it:

# Portal outage

Logins fail for **some** customers.

- Check the status page
- Post an update every 30 minutes
<h1>Portal outage</h1>
<p>Logins fail for <strong>some</strong> customers.</p>
<ul>
<li>Check the status page</li>
<li>Post an update every 30 minutes</li>
</ul>

Between the two, the parser held this tree in memory:

document
├── heading (level 1)
│   └── text "Portal outage"
├── paragraph
│   ├── text "Logins fail for "
│   ├── strong
│   │   └── text "some"
│   └── text " customers."
└── bullet list (tight)
    ├── list item
    │   └── paragraph → text "Check the status page"
    └── list item
        └── paragraph → text "Post an update every 30 minutes"

Tools that check or rewrite Markdown, such as linters and formatters, work on this tree too. That is why they can tell a # that starts a heading from a # inside a code block.

Renderers in code: markdown-it and others

Most applications that show Markdown use a library: the website, ticket tool or chat app passes the text to the library and inserts the HTML it returns. The library decides which flavor you get, and its options decide what is allowed.

Library Language Rules Where you meet it
markdown-it JavaScript CommonMark, plus tables and strikethrough; plugins VS Code's preview, many web apps, the examples on this site
cmark and commonmark.js C, JavaScript CommonMark (the reference implementations) Built into other tools and language bindings
Python-Markdown Python Close to the original Markdown.pl, not CommonMark MkDocs
kramdown Ruby Its own superset of Markdown Jekyll and GitHub Pages
Goldmark Go CommonMark-based Hugo

markdown-it and its defaults

markdown-it is the library that renders every Markdown example on this site, version 15.0.2 (September 2026). Its default preset follows the CommonMark specification and adds two extensions from GitHub Flavored Markdown: tables and strikethrough. Task lists, footnotes and alerts need plugins. In a Node.js project you would install it with npm install markdown-it and call it like this:

const markdownit = require("markdown-it");
const md = markdownit();                 // default options
const html = md.render("Power is **back** in *Deventer*.");
console.log(html);

The defaults are deliberately strict. Raw HTML in the source is escaped, so it shows up as text instead of becoming part of the page, and links with a javascript: address are refused. That makes the default safe for text typed by users; see HTML in Markdown and Safety for why that matters.

## Maintenance tonight

Portal **offline** from 22:00 to 23:00 UTC.
Use <b>the phone line</b> for urgent calls.

| System | Impact |
| ------ | ------ |
| Portal | ~~Partial~~ Full |
<h2>Maintenance tonight</h2>
<p>Portal <strong>offline</strong> from 22:00 to 23:00 UTC.
Use &lt;b&gt;the phone line&lt;/b&gt; for urgent calls.</p>
<table>
<thead>
<tr>
<th>System</th>
<th>Impact</th>
</tr>
</thead>
<tbody>
<tr>
<td>Portal</td>
<td><s>Partial</s> Full</td>
</tr>
</tbody>
</table>

Notice two things in the HTML. The <b> tags were escaped to &lt;b&gt;, so the reader sees them literally. And the line break after "UTC." is kept in the HTML but a browser shows both lines joined: a single newline is a soft line break.

Options change the output

Four options are worth knowing, because each one makes the same Markdown produce different HTML. The examples below set one option each (in markdown-it: markdownit({ breaks: true }) and so on).

breaks turns every single newline into a <br>, which is how GitHub comments and most chat apps behave:

Portal **offline** from 22:00 to 23:00 UTC.
Use the phone line for urgent calls.
<p>Portal <strong>offline</strong> from 22:00 to 23:00 UTC.<br>
Use the phone line for urgent calls.</p>

linkify turns bare web addresses into links. In markdown-it 15 only addresses with a scheme such as https:// are linked; a bare www. address stays plain text unless you switch on "fuzzy" links. Older guides that promise otherwise describe version 14.

Status: https://status.example.com or www.example.com
<p>Status: <a href="https://status.example.com">https://status.example.com</a> or www.example.com</p>

typographer replaces straight quotes with curly ones and a few character sequences with symbols. Good for prose, bad for commands that people copy:

Type "restart" -- it's safe (c) Kestrel Energy...
<p>Type “restart” – it’s safe © Kestrel Energy…</p>

The fourth, html, lets raw HTML through unchanged. Only switch it on for Markdown you wrote yourself, or sanitize the result afterward.

Pandoc: one document, many formats

Pandoc is a free command-line converter between document formats, written by John MacFarlane, who also wrote the CommonMark specification. It reads Markdown, HTML, Word (.docx), LaTeX and many more, and writes most of them back out. The latest release is 3.12 (as of October 2026). It is the usual answer when someone asks for the runbook "as a Word file" or a README as a standalone web page.

# Markdown to Word, for a colleague who does not use Git
pandoc portal-runbook.md -o portal-runbook.docx

# A complete HTML page (with <html>, <head> and a title) instead of a fragment
pandoc README.md --standalone --metadata title="Portal README" -o readme.html

# Read the input as GitHub Flavored Markdown instead of Pandoc's own dialect
pandoc --from gfm CHANGELOG.md -o changelog.docx

# The other way round: turn an old Word procedure into Markdown
pandoc old-procedure.docx -o old-procedure.md

Pandoc picks the formats from the file extensions, or from --from and --to. By default it reads Pandoc's Markdown, its own extended dialect, so if you write for GitHub, say --from gfm to get the same rules.

Converting from Word to Markdown is useful when a team moves old procedures into a repository, but expect to tidy up: colored text, merged table cells and comments have no Markdown equivalent and are dropped or simplified.

Markdown to PDF

Markdown-to-PDF conversion is harder than it sounds, because Markdown says nothing about pages: no paper size, margins, page breaks, headers or footers. Something has to lay the text out on pages, and that is a separate program. There are two common routes:

  • Via LaTeX or Typst. Pandoc converts the Markdown and hands it to a PDF engine: pandoc report.md -o report.pdf. The default engine is LaTeX, which is a large separate install; Typst is a smaller alternative (--pdf-engine=typst). The result looks like a printed book or paper.
  • Via HTML. Convert to HTML first, then print it to PDF with a browser ("Print → Save as PDF") or an HTML-to-PDF tool. The layout then comes from CSS, which web people already know, including print styles.

Whichever route you take, treat the PDF as an export, not as the source. Keep the Markdown in version control, change it there, and create a new PDF when needed; a PDF that is edited by hand drifts away from the source within weeks.

Static site generators

A static site generator converts a whole folder of Markdown files into a website: it renders each file, wraps it in a page template with navigation and search, and writes plain HTML files that any web server can host (see static hosting). The settings at the top of each file, the front matter, tell the generator the page title, date or layout.

Generator Markdown renderer Status (as of October 2026)
Jekyll kramdown by default Latest 4.4.1; GitHub Pages still runs Jekyll 3.10 with kramdown 2.4
Hugo Goldmark (CommonMark, compatible with GitHub Flavored Markdown) Frequent releases, v0.167.0 in September 2026
MkDocs Python-Markdown (not CommonMark) Last stable 1.6.1 (2024); 2.0 development builds with breaking changes appeared in 2026
Material for MkDocs (theme and plugins on top of MkDocs) Maintenance mode since November 2025: fixes only; the same team builds a successor, Zensical, still at version 0.0.x
Docusaurus MDX: Markdown plus JSX components Version 3.10
Astro GitHub Flavored Markdown, MDX via an integration Version 7

The renderer column matters more than it looks. A page that renders fine in VS Code (CommonMark) can come out differently in MkDocs, because Python-Markdown follows older rules; for example, it expects nested list items to be indented by four spaces. Test with the generator you actually publish with, and keep its version pinned.

MkDocs takes its settings from a mkdocs.yml file next to a docs/ folder; mkdocs serve shows the site locally and mkdocs build writes the HTML to site/:

site_name: Kestrel Portal documentation
nav:
  - Home: index.md
  - Runbooks:
      - Portal returns 503: runbooks/portal-503.md
      - Certificate renewal: runbooks/certificate-renewal.md
  - Changelog: changelog.md

Live preview in your editor

A live preview is a pane in an editor that shows the rendered Markdown and updates as you type. In Visual Studio Code, Ctrl+Shift+V opens the preview in place of the editor, and Ctrl+K followed by V opens it side by side. Most other editors, and note apps such as Obsidian, have something similar.

A preview catches the obvious mistakes: a heading without a space after the #, a table without its delimiter row, a list that swallowed the next paragraph. But it shows what the editor's renderer makes of the file. VS Code's preview uses markdown-it and targets CommonMark; its documentation says plainly that it does not support GitHub Flavored Markdown. It does add some extras of its own, such as math (with KaTeX) and Mermaid diagrams.

Task lists are a good example. GitHub renders them as checkboxes, but a plain CommonMark renderer such as markdown-it's default shows the brackets as text:

- [x] Change approved
- [ ] Rollback tested
<ul>
<li>[x] Change approved</li>
<li>[ ] Rollback tested</li>
</ul>

For a file that will live on GitHub, the most reliable preview is GitHub itself: the "Preview" tab when you edit a file or write a comment, or the rendered file in a pull request.

Choosing a route

You need Use
To check how a file looks while writing The editor's live preview, then the target's preview
A README or docs that live in a repository Nothing: GitHub and GitLab render them for you
A Word file or PDF for someone outside the team Pandoc (with a reference document or a PDF engine)
A documentation website with navigation and search A static site generator, built in CI
Markdown typed by users inside your own application A library such as markdown-it with safe defaults (raw HTML off), or a sanitizer on the output