php
73 lines · 10 steps
Building a small Markdown-to-HTML parser in PHP
A line-oriented state machine turns Markdown into HTML by tracking open paragraphs and lists as it walks each line.
Explained by
highlit
1<?php
2
3final class MarkdownParser
4{
5 public function toHtml(string $markdown): string
6 {
7 $lines = preg_split('/\r\n|\r|\n/', $markdown);
8 $html = [];
9 $paragraph = [];
10 $inList = false;
11
12 $flushParagraph = function () use (&$paragraph, &$html) {
13 if ($paragraph !== []) {
14 $html[] = '<p>' . $this->inline(implode(' ', $paragraph)) . '</p>';
15 $paragraph = [];
16 }
17 };
18
19 $closeList = function () use (&$inList, &$html) {
20 if ($inList) {
21 $html[] = '</ul>';
22 $inList = false;
23 }
24 };
25
26 foreach ($lines as $line) {
27 $trimmed = trim($line);
28
29 if ($trimmed === '') {
30 $flushParagraph();
31 $closeList();
32 continue;
33 }
34
35 if (preg_match('/^(#{1,6})\s+(.*)$/', $trimmed, $m)) {
36 $flushParagraph();
37 $closeList();
38 $level = strlen($m[1]);
39 $html[] = "<h{$level}>" . $this->inline($m[2]) . "</h{$level}>";
40 continue;
41 }
42
43 if (preg_match('/^[-*]\s+(.*)$/', $trimmed, $m)) {
44 $flushParagraph();
45 if (!$inList) {
46 $html[] = '<ul>';
47 $inList = true;
48 }
49 $html[] = '<li>' . $this->inline($m[1]) . '</li>';
50 continue;
51 }
52
53 $closeList();
54 $paragraph[] = $trimmed;
55 }
56
57 $flushParagraph();
58 $closeList();
59
60 return implode("\n", $html);
61 }
62
63 private function inline(string $text): string
64 {
65 $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
66 $text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
67 $text = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $text);
68 $text = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $text);
69 $text = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $text);
70
71 return $text;
72 }
73}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Line-oriented parsers stay simple when you track just enough state — an open paragraph and whether a list is active.
- 2Deferring output through flush closures lets you accumulate multi-line blocks and emit them only when a boundary appears.
- 3Escaping text before applying inline formatting keeps the output safe without breaking the markup you intentionally inject.
Related explainers
python
import datetime from dataclasses import dataclass
Parsing fixed-width records in Python
parsing
generators
dataclasses
Intermediate
8 steps
rust
use std::collections::HashMap; #[derive(Debug)] pub struct RequestHead {
Parsing an HTTP request head in Rust
parsing
error-handling
iterators
Intermediate
9 steps
javascript
function initScrollSpy() { const links = Array.from(document.querySelectorAll('.nav a[href^="#"]')); const sections = links .map((link) => document.querySelector(link.getAttribute('href')))
Building a scroll spy with IntersectionObserver
intersectionobserver
dom
event-driven
Intermediate
7 steps
python
from typing import Callable, Dict, Type class PluginRegistry:
A decorator-based plugin registry in Python
decorators
registry pattern
factory
Intermediate
9 steps
go
package humanize import ( "fmt"
Parsing human-readable byte sizes in Go
parsing
regex
lookup-table
Intermediate
8 steps
javascript
const ROLE_PERMISSIONS = { admin: ['users:read', 'users:write', 'billing:read', 'billing:write'], manager: ['users:read', 'billing:read'], member: ['users:read'],
Role-based permissions middleware in Express
authorization
middleware
rbac
Intermediate
9 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-small-markdown-to-html-parser-in-php-explained-php-c481/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.