# Build a Markdown-to-HTML Converter (JS)

> Write your own little Markdown-to-HTML converter in JavaScript - blocks, inline formatting, and escaping - built and run right in the browser.


---

# Build a Markdown-to-HTML Converter (JS)

Every README you have ever read started life as Markdown. Some library turned those
hashes and asterisks into headings and bold text. This weekend, you are going to be
that library.

We will build a small Markdown-to-HTML converter in plain JavaScript. You feed it text
like `# Hello` and it hands back `<h1>Hello</h1>`. No frameworks, no installs, no build
step - you write a function, you call it, you see HTML come out the other side.

## What you'll build

A function called `mdToHtml(text)` that takes a Markdown string and returns an HTML
string. By the end it handles:

- **Blocks** - headings (`#`), paragraphs, and bullet lists (`-`).
- **Inline formatting** - `**bold**`, `*italic*`, `` `code` ``, and `[links](url)`.
- **Escaping** - raw `<` and `>` in the source get neutralized so they show up as text
  instead of breaking your page.

It is the same shape as the real tools (marked, markdown-it). Smaller, yes - but the
core idea is identical, and once you have built one you will never look at a Markdown
renderer the same way again.

## The stack

JavaScript and regular expressions. That is the whole list. Everything runs in the
browser on this page - the code blocks below are live. You edit them, you hit run, you
watch the output change.

## This one runs in the browser

This is a **run-along** project. Every code block here is executable right where it
sits. You do not need to set up a project on your machine or install anything. Read a
phase, run its block, tweak it, run it again. That tight loop - change, run, see - is
how this stuff actually sinks in.

## Rough time

Two to three hours if you run every block and poke at them. Less if you skim, but the
poking is the point.

## What you'll learn

- How Markdown maps onto HTML, one rule at a time.
- The difference between **block-level** parsing (whole lines) and **inline** parsing
  (spans inside a line) - the single most useful idea in this whole guide.
- Writing regular expressions that capture parts of a match and reuse them.
- Why escaping HTML is not optional, and how a converter that skips it becomes a
  security hole.

## The shape of the journey

```mermaid
graph LR
  A[Raw Markdown] --> B[Split into lines]
  B --> C[Classify each block]
  C --> D[Format inline spans]
  D --> E[Escape raw HTML]
  E --> F[HTML output]
```

Phase 1 splits the input into lines and frames the loop. Phase 2 turns those lines into
block elements. Phase 3 handles the inline formatting inside them. Phase 4 stitches it
all together, adds escaping, and leaves you with one working converter you can extend.

Grab a coffee. Let's build a thing.


---

# The Plan: Lines to HTML

Before we write a single regex, let's agree on what we are actually doing. A converter
is a translator. Markdown goes in, HTML comes out, and our job is to write down the
translation rules.

## What translates to what

Markdown is a tiny language. Most of it is a handful of one-line patterns. Here is the
slice we care about:

| You write              | You get                          |
| ---------------------- | -------------------------------- |
| `# Title`              | `<h1>Title</h1>`                 |
| `## Subtitle`          | `<h2>Subtitle</h2>`              |
| `- item`               | a list item in a `<ul>`          |
| `Some text.`           | `<p>Some text.</p>`              |
| `**bold**`             | `<strong>bold</strong>`          |
| `*italic*`             | `<em>italic</em>`                |
| `` `code` ``           | `<code>code</code>`              |
| `[text](url)`          | `<a href="url">text</a>`         |

Notice the split in that table. The top four rules look at a **whole line** - a `#` at
the start makes the entire line a heading. The bottom four work **inside** a line -
`**bold**` could appear in the middle of a paragraph or a heading.

That split is the most important idea in this guide. We have two kinds of rules:

- **Block rules** decide what each line *becomes* (a heading, a list item, a paragraph).
- **Inline rules** decorate the *text within* a block (bold, italic, code, links).

We will handle blocks first (Phase 2), inline second (Phase 3). Keeping them separate is
what keeps the code readable. Try to do both at once and you get a tangle.

## Why lines?

Markdown is line-oriented. The thing at the *start* of a line decides what that line is.
`#` at the start means heading. `-` at the start means list item. Nothing special at the
start means paragraph.

So the first move of almost every Markdown parser is the same: chop the input into
lines and look at each one. JavaScript hands us that for free with `split`.

```mermaid
graph TD
  A["# Hello\n\n- a\n- b"] --> B[split on newline]
  B --> C["[ '# Hello', '', '- a', '- b' ]"]
  C --> D[loop over each line]
```

## Splitting the input

Let's prove this works. We will take a small Markdown document, split it into an array of
lines, and print each one with its index so we can see exactly what the loop will be
chewing on.

Before you run this, guess how many lines you'll get - remember, `split` keeps blank lines too.

```js runnable
const markdown = `# My Notes

Here is a paragraph.

- first item
- second item`;

// The whole game starts here: text becomes an array of lines.
const lines = markdown.split("\n");

console.log("Got", lines.length, "lines:\n");

lines.forEach((line, i) => {
  // Show the index and the raw content, quoted so blank lines are visible.
  console.log(`${i}: "${line}"`);
});
```

Run that. You should see six lines, including the two blank ones (index 1 and 4). Those
blank lines matter later - they are how Markdown separates one paragraph from the next -
so it is good that `split` keeps them.

## Framing the loop

Now the skeleton everything else hangs on. We loop over the lines and, for each one,
decide what it is. Right now we only know how to *classify* a line, not convert it - but
classifying is the first half of the job, and it shows you the shape of the code we will
fill in over the next phases.

**Your turn.** Write `classify` yourself - fill it in and hit Run, the checks underneath
tell you whether it works. My version is in the next block whenever you want it.

```js runnable
function classify(line) {
  // Return one of these strings, based on `line`:
  //   "heading"    - line starts with "# "
  //   "list-item"  - line starts with "- "
  //   "blank"      - line is empty or only whitespace
  //   "paragraph"  - anything else
}

// --- checks: fix your function until this prints "All good." ---
if (classify("# My Notes") !== "heading") throw new Error(`classify("# My Notes") should be "heading", got: ${classify("# My Notes")}`);
if (classify("- first item") !== "list-item") throw new Error(`classify("- first item") should be "list-item", got: ${classify("- first item")}`);
if (classify("") !== "blank") throw new Error(`classify("") should be "blank", got: ${classify("")}`);
if (classify("   ") !== "blank") throw new Error(`classify("   ") should be "blank" (whitespace counts too), got: ${classify("   ")}`);
if (classify("Just some text.") !== "paragraph") throw new Error(`classify("Just some text.") should be "paragraph", got: ${classify("Just some text.")}`);
console.log("All good.");
```

Stuck on blank lines? A string has a `.trim()` method - what does it return for `"   "`?

### One way to write it

```js runnable
const markdown = `# My Notes

Here is a paragraph.

- first item`;

const lines = markdown.split("\n");

function classify(line) {
  if (line.startsWith("# ")) return "heading";
  if (line.startsWith("- ")) return "list-item";
  if (line.trim() === "") return "blank";
  return "paragraph";
}

const output = [];
for (const line of lines) {
  const kind = classify(line);
  output.push(`${kind.padEnd(10)} <- "${line}"`);
}

console.log(output.join("\n"));
```

Run it. Each line now has a label. `# My Notes` is a heading, the bullet is a list-item,
the blank line is blank, and the rest is a paragraph.

This is the real architecture in miniature. A Markdown parser is fundamentally a thing
that walks lines, classifies each one, and emits the matching HTML. Everything from here
is filling in the "emit the matching HTML" part - making `classify` smarter and turning
those labels into real tags.

## Where we are

You have a converter's skeleton: input split into lines, and a function that knows what
each line is. You also have the mental model - blocks versus inline - that the rest of
the build rests on.

Next phase we replace those labels with actual HTML tags. Bring the `classify` idea with
you; it is about to grow up.


---

# Block Elements

Last phase we labeled each line. Now we make those labels do work: a heading line becomes
an `<h1>`, a bullet becomes an `<li>` inside a `<ul>`, and everything else becomes a
`<p>`. This is block-level conversion - deciding what each line *is*.

## Regex with capture groups

A regular expression can do two things at once: confirm a line matches a pattern *and*
pull out the part you care about. The part you pull out is a **capture group** -
anything inside parentheses.

Take a heading. The pattern is "a hash, a space, then the rest of the line":

```js runnable
const line = "# My Notes";

// ^      start of line
// #\s    a hash followed by a space
// (.*)   capture everything after it
const match = line.match(/^#\s(.*)/);

console.log("Matched?", match !== null);
console.log("Captured text:", match[1]);
```

`match[0]` is the whole match; `match[1]` is the first capture group - the heading text
without the `#`. That captured text is exactly what goes between `<h1>` and `</h1>`.

## Headings at six levels

Markdown has six heading levels, `#` through `######`. Writing six separate patterns would
work, but there's a shorter way that handles all of them at once.

**Your turn.** Write a `heading` function that turns a line into its HTML tag, or returns
`null` if the line isn't a heading at all. Fill it in, run it, and let the checks tell you
when it's right. My version is right after.

```js runnable
function heading(line) {
  // Return an HTML heading tag string, e.g. "<h1>Big</h1>", if `line` starts
  // with 1 to 6 "#" characters followed by a space. The number of "#"s sets
  // the heading level. If `line` is not a heading, return null.
}

// --- checks: fix your function until this prints "All good." ---
if (heading("# Big") !== "<h1>Big</h1>") throw new Error(`heading("# Big") should be "<h1>Big</h1>", got: ${heading("# Big")}`);
if (heading("### Smaller") !== "<h3>Smaller</h3>") throw new Error(`heading("### Smaller") should be "<h3>Smaller</h3>", got: ${heading("### Smaller")}`);
if (heading("###### Tiny") !== "<h6>Tiny</h6>") throw new Error(`heading("###### Tiny") should be "<h6>Tiny</h6>", got: ${heading("###### Tiny")}`);
if (heading("Not a heading") !== null) throw new Error(`heading("Not a heading") should be null, got: ${heading("Not a heading")}`);
console.log("All good.");
```

Stuck on handling all six levels with one pattern? A quantifier like `{1,6}` inside a
regex means "repeat the last thing between 1 and 6 times" - the same idea as `*` for
"zero or more".

### One way to write it

```js runnable
function heading(line) {
  // (#{1,6}) one to six hashes; \s a space; (.*) the text
  const m = line.match(/^(#{1,6})\s(.*)/);
  if (!m) return null;
  const level = m[1].length;       // number of hashes = heading level
  return `<h${level}>${m[2]}</h${level}>`;
}

console.log(heading("# Big"));
console.log(heading("### Smaller"));
console.log(heading("Not a heading"));  // returns null
```

Run it. One hash gives `<h1>`, three hashes give `<h3>`, and a plain line returns `null`
so we know to try other rules. Returning `null` on no-match is a pattern we will lean on:
each block rule either claims a line or passes.

## Lists are the tricky one

Headings and paragraphs are one line in, one tag out. Lists are different. Several
consecutive `- item` lines need to be wrapped in a *single* `<ul>`:

```
- a          <ul>
- b    -->     <li>a</li>
                <li>b</li>
              </ul>
```

So a list item alone is not enough; we need to know whether we are already inside a list.
That means keeping a little state as we walk the lines: are we currently in a list or
not? When we hit the first `-`, open a `<ul>`. When we hit a non-`-` line, close it.

```mermaid
graph TD
  A[next line] --> B{starts with '- '?}
  B -->|yes, not in list| C[open ul, emit li]
  B -->|yes, in list| D[emit li]
  B -->|no, in list| E[close ul, handle line]
  B -->|no, not in list| F[handle line normally]
```

## The block converter

Here it all comes together. We walk the lines, track whether we are inside a list, and
emit the right tags. Paragraphs are the fallback - any non-blank line that is not a
heading or list item.

Before you run this, guess how many `<li>` tags show up in the output.

```js runnable
function toBlocks(markdown) {
  const lines = markdown.split("\n");
  const out = [];
  let inList = false;

  function closeList() {
    if (inList) {
      out.push("</ul>");
      inList = false;
    }
  }

  for (const line of lines) {
    const heading = line.match(/^(#{1,6})\s(.*)/);
    const item = line.match(/^-\s(.*)/);

    if (item) {
      if (!inList) {
        out.push("<ul>");
        inList = true;
      }
      out.push(`  <li>${item[1]}</li>`);
    } else if (heading) {
      closeList();
      const level = heading[1].length;
      out.push(`<h${level}>${heading[2]}</h${level}>`);
    } else if (line.trim() === "") {
      closeList();
      // blank line: paragraph separator, emit nothing
    } else {
      closeList();
      out.push(`<p>${line}</p>`);
    }
  }

  closeList(); // a list at the very end still needs closing
  return out.join("\n");
}

const sample = `# Shopping

Things to buy:

- milk
- bread
- coffee

Done.`;

console.log(toBlocks(sample));
```

Run it. You get a clean tree: an `<h1>`, a `<p>`, a `<ul>` with three `<li>`s, and a
final `<p>`. Notice the list opens once and closes once, even though three items went into
it - that is the `inList` flag earning its keep.

That last `closeList()` after the loop is the kind of detail that bites people. Without
it, a document that ends on a list item never emits its closing `</ul>`. Delete that line,
re-run, and watch the broken output. Then put it back.

## Where we are

Your converter now produces real block structure: headings at any level, lists that wrap
correctly, and paragraphs for everything else. What it does *not* do yet is anything
inside those blocks - `**milk**` would come out as literal asterisks.

That is Phase 3: reaching inside each block and formatting the spans.


---

# Inline Formatting

Block rules decide what a line *is*. Inline rules decorate the text *within* it. A
heading can contain `**bold**`; a paragraph can contain a `[link](url)`. This phase
handles those spans, and it works the same regardless of which block the text came from.

The tool here is `String.replace` with a regex and a replacement string. Where capture
groups in Phase 2 told us what to wrap, here they get pasted straight into the output.

## Bold and italic

Bold is `**text**`, italic is `*text*`. The replacement uses `$1` to mean "whatever the
first capture group caught":

Guess what this prints before you run it.

```js runnable
let text = "This is **bold** and this is *italic*.";

// \*\*(.+?)\*\*  ->  two literal stars, then capture, then two stars
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");

// \*(.+?)\*      ->  one star, capture, one star
text = text.replace(/\*(.+?)\*/g, "<em>$1</em>");

console.log(text);
```

Two things to notice. The `g` flag means *global* - replace every match, not only the
first. And `.+?` is **non-greedy**: the `?` tells it to grab as few characters as
possible. Without it, `*a* and *b*` would match from the first star all the way to the
last, swallowing the text between. Try removing the `?` and re-running to see the mess.

## Order matters: bold before italic

There is a trap hiding in those two patterns. `**bold**` is also four stars, and the
italic pattern `\*(.+?)\*` could chew into it. The fix is order: handle `**` *before*
`*`. By the time the italic rule runs, the bold has already become `<strong>` tags and
its stars are gone.

```js runnable
function inline(text) {
  text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>"); // bold FIRST
  text = text.replace(/\*(.+?)\*/g, "<em>$1</em>");             // italic second
  return text;
}

console.log(inline("**important** and *subtle*"));
console.log(inline("a *little* emphasis here"));
```

Run it. Both come out clean. Swap the two lines so italic runs first, re-run, and you will
see the bold break - the italic rule eats the inner stars and leaves a stray `**`. Order
is not cosmetic here; it is correctness.

## Inline code

Inline code is text between backticks: `` `code` ``. Same shape, different delimiter:

```js runnable
let text = "Call `mdToHtml(text)` to convert.";

text = text.replace(/`(.+?)`/g, "<code>$1</code>");

console.log(text);
```

In a fuller parser you would also want code spans to be *immune* to the other rules -
`` `**not bold**` `` should stay literal. We are keeping it small here, but it is worth
knowing that real parsers pull code out first and stitch it back last for exactly that
reason.

## Links

Links are the one with two captures. `[text](url)` becomes
`<a href="url">text</a>` - the text and the URL land in different places, so we capture
both and reorder them:

```js runnable
let text = "Read the [docs](https://example.com) for more.";

// \[(.+?)\]  capture the text inside square brackets
// \((.+?)\)  capture the url inside parens
text = text.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');

console.log(text);
```

`$1` is the link text, `$2` is the URL. Notice they swap places in the output - that
reordering is something a plain find-and-replace could never do, but a capture group makes
trivial.

## All inline rules together

Here is the last piece of this phase: one `inline` function that chains all four rules -
links, code, bold, italic - the function we will plug into the converter next phase.

**Your turn.** You already have all four `.replace` calls from above, and you already saw
what goes wrong when bold and italic run in the wrong order. Chain the four rules into one
function and get every check below to pass. My version is right after.

```js runnable
function inline(text) {
  // Chain the four .replace() rules from above into one function:
  //   links:  [text](url)   -> <a href="url">text</a>
  //   code:   `code`        -> <code>code</code>
  //   bold:   **text**      -> <strong>text</strong>
  //   italic: *text*        -> <em>text</em>
  // The order you chain them in matters - get it wrong and some of the
  // checks below will fail even though each individual rule "looks right".
}

// --- checks: fix your function until this prints "All good." ---
if (inline("**bold**") !== "<strong>bold</strong>") throw new Error(`inline("**bold**") should be "<strong>bold</strong>", got: ${inline("**bold**")}`);
if (inline("*italic*") !== "<em>italic</em>") throw new Error(`inline("*italic*") should be "<em>italic</em>", got: ${inline("*italic*")}`);
if (inline("`code`") !== "<code>code</code>") throw new Error(`inline("\`code\`") should be "<code>code</code>", got: ${inline("`code`")}`);
if (inline("[text](url)") !== '<a href="url">text</a>') throw new Error(`inline("[text](url)") should be '<a href="url">text</a>', got: ${inline("[text](url)")}`);
if (inline("**a** and *b*") !== "<strong>a</strong> and <em>b</em>") throw new Error(`inline("**a** and *b*") should be "<strong>a</strong> and <em>b</em>", got: ${inline("**a** and *b*")}`);
if (inline("[**bold link**](url)") !== '<a href="url"><strong>bold link</strong></a>') throw new Error(`inline("[**bold link**](url)") should be '<a href="url"><strong>bold link</strong></a>', got: ${inline("[**bold link**](url)")}`);
console.log("All good.");
```

Stuck on ordering? Links and code use different delimiters (`[]()` and `` ` ``) so they
can't collide with `*`, but they *contain* text that bold and italic will also try to
match - which order avoids that trap?

### One way to write it

```js runnable
function inline(text) {
  return text
    .replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>') // links
    .replace(/`(.+?)`/g, "<code>$1</code>")                // inline code
    .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")      // bold
    .replace(/\*(.+?)\*/g, "<em>$1</em>");                 // italic
}

const sample =
  "See the **important** `config` setting in the [guide](https://docs.dev), *please*.";

console.log(inline(sample));
```

Run it. One line of mixed Markdown, and every span comes out as the right tag: a link, a
code span, bold, and italic, all in one pass. Chaining `.replace` calls like this reads
top to bottom as a list of rules, which is about as clear as text transformation gets.
Links and code run first because they have their own delimiters and won't collide with
each other - but their *captured text* still needs bold and italic applied afterward,
which is exactly what happens here since `inline` runs once, in order, over the whole
string.

```mermaid
graph LR
  A[raw text] --> B[links]
  B --> C[code]
  C --> D[bold]
  D --> E[italic]
  E --> F[formatted html]
```

## Where we are

You now have two halves of a converter. `toBlocks` from Phase 2 builds the structure;
`inline` from this phase decorates the text. They do not talk to each other yet - that is
the final phase, where we wire them together, add HTML escaping, and end up with one
function you can throw a whole document at.


---

# Putting It Together

You have the two halves. `toBlocks` builds structure from lines; `inline` formats the
spans within text. This phase joins them into a single `mdToHtml(text)` function, adds the
one piece we have been deferring - escaping - and hardens it against a few edge cases.

## The escaping problem

Here is a question that decides whether your converter is a toy or a tool: what happens
when the Markdown source itself contains HTML?

Before you run this, guess what the printed `<p>` line will actually do if it lands on a
real web page.

```js runnable
const evil = "Hello <script>alert('gotcha')</script>";

// Our converter so far would pass that <script> straight through into the page.
console.log("Output would contain a live script tag:");
console.log(`<p>${evil}</p>`);
```

If you drop that output into a real page, the script runs. That is the classic injection
hole, and it is why **escaping is not optional**. Before we interpret any Markdown, we
need to turn the characters that give HTML its power - `<`, `>`, and `&` - into text that
*displays* instead of executing.

**Your turn.** Write `escapeHtml`. You need three `.replace` calls, one per character -
the order you put them in matters more than it looks like it should. Fill it in and let
the checks tell you when it's right. My version is right after.

```js runnable
function escapeHtml(text) {
  // Replace the three characters that are dangerous in HTML with their
  // display-safe equivalents:
  //   &  ->  &amp;
  //   <  ->  &lt;
  //   >  ->  &gt;
  // The order you do these replacements in matters.
}

// --- checks: fix your function until this prints "All good." ---
if (escapeHtml("<") !== "&lt;") throw new Error(`escapeHtml("<") should be "&lt;", got: ${escapeHtml("<")}`);
if (escapeHtml(">") !== "&gt;") throw new Error(`escapeHtml(">") should be "&gt;", got: ${escapeHtml(">")}`);
if (escapeHtml("a & b") !== "a &amp; b") throw new Error(`escapeHtml("a & b") should be "a &amp; b", got: ${escapeHtml("a & b")}`);
if (escapeHtml("<script>") !== "&lt;script&gt;") throw new Error(`escapeHtml("<script>") should be "&lt;script&gt;", got: ${escapeHtml("<script>")}`);
console.log("All good.");
```

Stuck on the order? Think about what happens to the `&lt;` you just created if the `&`
rule runs *after* the `<` rule - would it stay `&lt;`, or get escaped a second time?

### One way to write it

```js runnable
function escapeHtml(text) {
  return text
    .replace(/&/g, "&amp;")   // must run first
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

console.log(escapeHtml("Hello <script>alert('x')</script> & friends"));
```

Run it. The `<script>` is now inert text - it will *display* as `<script>` on the page
instead of executing. That ordering matters: escape `&` first, or you turn your own
`&lt;` into `&amp;lt;`. If you escaped `<` and `>` first and `&` last, every check above
still looks reasonable to write - it's only `escapeHtml("<script>")` that gives it away,
because by the time the `&` rule runs, it is escaping the `&` inside the `&lt;` this
function just created.

## The order of operations

So the full pipeline, start to finish, is:

1. **Escape** the raw input - neutralize any HTML in the source.
2. **Block pass** - split into lines, build headings, lists, paragraphs.
3. **Inline pass** - format bold, italic, code, links within each block's text.

Escape first, always. If you formatted first and escaped after, you would escape your own
`<strong>` tags right back into text. Escaping has to happen while the angle brackets are
still the *source's*, before any of yours exist.

```mermaid
graph LR
  A[raw markdown] --> B[escape html]
  B --> C[block pass]
  C --> D[inline pass]
  D --> E[safe html]
```

## The complete converter

Here is everything, in one place, working. The inline pass runs on the *text* of each
block, not on the tags we generate - that is why `inline` is called on the captured
heading text and list text, not on the whole output string.

```js runnable
function escapeHtml(text) {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

function inline(text) {
  return text
    .replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
    .replace(/`(.+?)`/g, "<code>$1</code>")
    .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
    .replace(/\*(.+?)\*/g, "<em>$1</em>");
}

function mdToHtml(markdown) {
  const lines = escapeHtml(markdown).split("\n"); // escape first, then split
  const out = [];
  let inList = false;

  const closeList = () => {
    if (inList) { out.push("</ul>"); inList = false; }
  };

  for (const line of lines) {
    const heading = line.match(/^(#{1,6})\s(.*)/);
    const item = line.match(/^-\s(.*)/);

    if (item) {
      if (!inList) { out.push("<ul>"); inList = true; }
      out.push(`  <li>${inline(item[1])}</li>`);
    } else if (heading) {
      closeList();
      const level = heading[1].length;
      out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
    } else if (line.trim() === "") {
      closeList();
    } else {
      closeList();
      out.push(`<p>${inline(line)}</p>`);
    }
  }

  closeList();
  return out.join("\n");
}

const doc = `# Weekend Build

We made a converter with **bold**, *italic*, and \`code\`.

Things it handles:

- [links](https://example.com)
- raw <tags> that get escaped
- mixed **emphasis** in lists

That's a wrap.`;

console.log(mdToHtml(doc));
```

Run it. That is a full Markdown document going in and clean, safe HTML coming out. The
heading is formatted, the list items carry their links and bold, and the `<tags>` in the
source show up as escaped text instead of breaking anything. Every piece you built across
four phases is in that one function.

## Edge cases worth knowing

What you built is real, but it is deliberately small. A few rough edges to be aware of:

| Edge case                        | What happens now              | The grown-up fix                          |
| -------------------------------- | ----------------------------- | ----------------------------------------- |
| Unclosed `**bold`                | left as literal text          | match leniently or warn                   |
| Nested `**a *b* c**`             | works, since order is right   | a real grammar handles deep nesting       |
| `` `**not bold**` `` in code     | the bold *would* still apply  | extract code spans first, restore last    |
| `# ` with no text                | empty heading tag             | trim and skip if blank                    |
| Links with `)` in the URL        | regex stops at the first `)`  | a stricter URL pattern                    |

None of these are flaws in your understanding - they are the line between a weekend build
and a production library. The real parsers (marked, markdown-it) spend most of their code
on exactly these corners.

## Extend it

You have a working base. Here are the next moves, roughly easiest to hardest:

- **Ordered lists.** Match `^\d+\.\s(.*)` and wrap in `<ol>` the same way you did `<ul>`.
- **Blockquotes.** Lines starting with `> ` become `<blockquote>` content.
- **Horizontal rules.** A line of `---` on its own becomes `<hr>`.
- **Code blocks.** Lines fenced by triple backticks become `<pre><code>` - and skip
  inline formatting inside them.
- **Render it live.** Drop `mdToHtml` into a page, wire a `<textarea>` to a `<div>`, and
  set the div's `innerHTML` on every keystroke. Now you have a live preview editor.

## Where we landed

You started with a string and a plan. You now have `mdToHtml` - a converter that splits
lines, builds blocks, formats inline spans, and escapes anything dangerous. It is the
same architecture the big libraries use, small enough to hold in your head and yours to
grow.

That is the whole weekend, in one function. Go feed it a README.
