Most Markdown renderers are very impressive pieces of software.
This is not trying to be one of them.
md-render is a small, dependency-free Markdown-to-HTML renderer for a specific kind of writing: essays, notes, articles, and archives that need predictable HTML with a few editorial conveniences baked in. It is not a CommonMark implementation. It is not a plugin system. It does not want to become a kitchen sink.
It renders the Markdown I actually write, with the quirks I actually want.
npm install md-renderimport { renderMarkdown } from 'md-render';
const html = renderMarkdown(`# You need more engineers
Most startups need more engineers, particularly in teams other than product development.
Tags: advice, startups, operations`);
console.log(html);This returns:
<h1 id="you-need-more-engineers" tabindex="-1">You need more engineers</h1>
<p>Most startups need more engineers, particularly in teams other than product development.</p>
<ul class="tags"><li>advice</li><li>startups</li><li>operations</li></ul>There is one mode:
renderMarkdown('This is **fine**.', 'inline');Which returns:
This is <strong>fine</strong>.That is the whole API.
Markdown is great until it becomes infrastructure.
You write a few posts. Then you add footnotes. Then you want images to be figures. Then you want smart quotes because straight quotes look like a spreadsheet got involved. Then you want headings to have IDs. Then you want CSV tables because writing pipe tables by hand is a punishment nobody deserves.
At this point, you can either bolt together a large parser, a handful of plugins, and a post-processing step, or you can use a small renderer that has opinions.
md-render chooses opinions.
Renders a Markdown string into block HTML.
renderMarkdown('# Hello\n\nThis is **Markdown**.');<h1 id="hello" tabindex="-1">Hello</h1>
<p>This is <strong>Markdown</strong>.</p>Passing null or undefined returns an empty string. Passing anything other than a string throws a TypeError.
Renders inline Markdown without wrapping the result in a paragraph.
renderMarkdown('A -> B, but `3/4` stays literal.', 'inline');A → B, but <code>3/4</code> stays literal.There are no options. This is deliberate.
Headings render from # through ######.
All headings get stable IDs and tabindex="-1".
## Startup leadership is about saying no to good ideas<h2 id="startup-leadership-is-about-saying-no-to-good-ideas" tabindex="-1">Startup leadership is about saying no to good ideas</h2>Repeated headings are disambiguated:
## Same
## Same
### Same<h2 id="same" tabindex="-1">Same</h2>
<h2 id="same-2" tabindex="-1">Same</h2>
<h3 id="same-3" tabindex="-1">Same</h3>Plain text becomes paragraphs.
Most software businesses sell tools.<p>Most software businesses sell tools.</p>HTML input is escaped. Existing HTML entities are preserved.
Unordered lists, ordered lists, nested lists, mixed lists, and list items with paragraph children are supported.
- A good idea
- Another good idea
- A smaller good idea
1. Start narrow
2. Win the market
3. Expand laterCompact two-item lists render compactly. Longer or nested lists render across multiple lines, which is easier to read in generated HTML.
List items can also contain paragraphs:
- First paragraph.
Second paragraph in the same item.
- Another item.Blockquotes render as blockquotes with paragraph content.
> All models are wrong, but some are useful.
>
> — George Box<blockquote>
<p>All models are wrong, but some are useful.</p>
<p>— George Box</p>
</blockquote>Triple-backtick code fences render as escaped code blocks.
```
this = 'some code';
that = {
that: 'this'
}
```<pre><code>this = 'some code';
that = {
that: 'this'
}
</code></pre>Typography replacements do not run inside code fences or inline code.
Pipe tables render as HTML tables.
| Product initiatives | Technical initiatives |
|---------------------|-----------------------|
| Expand templates | Optimise database |
| Add analytics | Centralise logs |Alignment is supported:
| Left | Center | Right |
|:-----|:------:|------:|
| a | b | c |<th style="text-align:left">Left</th>
<th style="text-align:center">Center</th>
<th style="text-align:right">Right</th>CSV tables are supported when fenced as csv.
```csv
a,b,c
1,2,3
4,5,6
```<table>
<thead>
<tr><th>a</th><th>b</th><th>c</th></tr>
</thead>
<tbody>
<tr><td>1</td><td>2</td><td>3</td></tr>
<tr><td>4</td><td>5</td><td>6</td></tr>
</tbody>
</table>Raw CSV is not auto-detected. This is important because prose often contains commas, numbers, and lines that only look meaningful to an overeager parser.
Quoted CSV cells are supported, including doubled quotes.
Images render as figures.
<figure><img src="/chart.svg" alt="Chart"><figcaption class="caption">Chart</figcaption></figure>The alt text becomes the caption by default.
Footnote references work inside captions. The figcaption gets the linked reference, while the alt text gets readable fallback copy:
![Chart[^1]](/chart.svg)
[^1]: Source note.<figure><img src="/chart.svg" alt="Chart. Source note 1."><figcaption class="caption">Chart<sup class="footnote-ref"><a href="#fn1" id="fnref1">1</a></sup></figcaption></figure>An explicit title becomes the caption instead:
<figure><img src="/image.jpg" alt="Alt text"><figcaption class="caption">Caption text</figcaption></figure>Empty alt text renders no caption.
Footnotes use the familiar reference syntax.
Read this[^1].
[^1]: This is the footnote.<p>Read this<sup class="footnote-ref"><a href="#fn1" id="fnref1">1</a></sup>.</p>
<h2>Footnotes</h2>
<ol class="footnotes-list">
<li id="fn1" class="footnote-item"><p>This is the footnote. <a href="#fnref1" class="footnote-backref">↩︎</a></p></li>
</ol>Footnotes are scoped per article section. In practice, this means each # H1 starts a new footnote scope.
That matters if you keep a full archive in one Markdown file:
# First Article
Read this[^1].
[^1]: First note.
# Second Article
Read that[^1].
[^1]: Second note.Both articles can use [^1] without fighting each other.
Missing footnote definitions are left alone. A renderer should not invent certainty where none exists.
Article metadata written as Tags: becomes a tag list.
Tags: advice, startups, operations, technology<ul class="tags"><li>advice</li><li>startups</li><li>operations</li><li>technology</li></ul>This is intentionally specific. It exists because article archives often end this way.
Markdown horizontal rules render as <hr>.
---<hr>iA Writer-style page breaks render as an <hr> with a print/export styling hook. Write three plus marks on a line by themselves, after an empty line.
Before
+++
After<p>Before</p>
<hr class="page-break">
<p>After</p>Use CSS like this when printing or exporting the HTML:
.page-break {
break-after: page;
}HTML comments are ignored.
Before
<!-- editorial note -->
After<p>Before</p>
<p>After</p>This is **strong**, _emphasised_, ==marked==, and ~~deleted~~.<p>This is <strong>strong</strong>, <em>emphasised</em>, <mark>marked</mark>, and <del>deleted</del>.</p>Inline code uses backticks.
Use `path/to/file`, `3/4`, and `--` literally.<p>Use <code>path/to/file</code>, <code>3/4</code>, and <code>--</code> literally.</p>Inline code is the escape hatch for paths, code, commands, and anything else that should not be made nicer by a typography pass.
Markdown links are supported.
[External](https://example.com)
[Internal](/page)External links open in a new window:
<a href="https://example.com" target="_blank" rel="noopener noreferrer">External</a>Relative links do not:
<a href="/page">Internal</a>Autolinks are also supported:
<https://example.com>Dangerous URLs, such as javascript: links, are filtered.
Backslash escapes keep Markdown characters literal.
\*not italic\* and \[not a link](/page)<p>*not italic* and [not a link](/page)</p>This renderer has a point of view about text.
Straight quotes become nice quotes:
"This is quoted," she said.
'This is quoted,' she said.
It's Brandon's renderer.“This is quoted,” she said.
‘This is quoted,’ she said.
It’s Brandon’s renderer.ASCII arrows become real arrows:
A -> B
B <- A
A <-> BA → B
B ← A
A ↔ BCommand key shortcuts become the command symbol:
cmd-s
Cmd Shift P⌘-s
⌘ Shift PCircled number shortcuts are supported from (1) through (20):
(1) Discover
(2) Decide
(10) Ship① Discover
② Decide
⑩ ShipTouching numeric ranges get en dashes:
Read pages 10-20 and 1999-2001.Read pages 10–20 and 1999–2001.Ellipses become ellipses:
Wait... what?Wait… what?Numbers-only multiplication and division shortcuts become readable math:
3*3
3 / 43 × 3
3 ÷ 4Multiplication requires touching numbers, while division requires spaces around the slash. 3 * 3, 3/4, 9/11, a/b, and path/to/file are left alone.
md-render escapes raw HTML.
It also filters dangerous link URLs. For example, javascript: links are rendered as plain text rather than clickable anchors.
Links whose href starts with / or # are treated as relative and do not get target="_blank". Everything else safe enough to render as a link gets target="_blank" and rel="noopener noreferrer".
This does not make it a full HTML sanitizer. If you use the output in an unusual environment, you still need to understand that environment.
md-render does not try to parse all of Markdown.
It does not support plugins. It does not expose a pile of switches. It does not promise CommonMark compliance. It does not preserve arbitrary raw HTML. It does not auto-link your pet glossary. It does not try to infer tables from unfenced CSV.
This is a feature, not a gap.
The goal is not to accept every possible input. The goal is to make a known body of writing render cleanly, predictably, and without a dependency tree.
npm install
npm test
npm run lintThe package is ESM-only and requires Node 18 or newer.
There are no runtime dependencies.
MIT.