Search by

Lightweight standalone CommonMark Markdown to HTML5 parser — no external dependencies

Package info

github.com/jmcollin/php-markdown

pkg:composer/php-markdown/parser

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0


README

A lightweight, standalone PHP library (8.2–8.4) that parses CommonMark-flavored Markdown and converts it into clean, semantic HTML5. Zero runtime dependencies.

PHP PSR-12 No dependencies License

Requirements

  • PHP 8.2 to 8.4
  • PHP extensions: ctype, intl, mbstring
  • No external packages required for core usage

Installation

Via Composer (recommended)

composer require php-markdown/parser

Manual (no Composer)

Copy the src/ directory into your project, then require the files in this order:

require '/path/to/src/Exception/ParseException.php';
require '/path/to/src/Node/NodeInterface.php';
require '/path/to/src/Node/BlockNodeInterface.php';
require '/path/to/src/Node/InlineNodeInterface.php';
require '/path/to/src/Node/Block/DocumentNode.php';
require '/path/to/src/Node/Block/HeadingNode.php';
require '/path/to/src/Node/Block/ParagraphNode.php';
require '/path/to/src/Node/Block/BlockquoteNode.php';
require '/path/to/src/Node/Block/ListNode.php';
require '/path/to/src/Node/Block/ListItemNode.php';
require '/path/to/src/Node/Block/FencedCodeNode.php';
require '/path/to/src/Node/Block/IndentedCodeNode.php';
require '/path/to/src/Node/Block/HorizontalRuleNode.php';
require '/path/to/src/Node/Block/TableNode.php';
require '/path/to/src/Node/Block/TableRowNode.php';
require '/path/to/src/Node/Block/TableCellNode.php';
require '/path/to/src/Node/Block/RawHtmlBlockNode.php';
require '/path/to/src/Node/Block/ColumnsNode.php';
require '/path/to/src/Node/Block/FootnoteDefinitionNode.php';
require '/path/to/src/Node/Block/FootnotesContainerNode.php';
require '/path/to/src/Node/Inline/TextNode.php';
require '/path/to/src/Node/Inline/EmphasisNode.php';
require '/path/to/src/Node/Inline/StrongNode.php';
require '/path/to/src/Node/Inline/StrikethroughNode.php';
require '/path/to/src/Node/Inline/CodeNode.php';
require '/path/to/src/Node/Inline/LinkNode.php';
require '/path/to/src/Node/Inline/ImageNode.php';
require '/path/to/src/Node/Inline/AutolinkNode.php';
require '/path/to/src/Node/Inline/HardBreakNode.php';
require '/path/to/src/Node/Inline/SoftBreakNode.php';
require '/path/to/src/Node/Inline/HtmlEntityNode.php';
require '/path/to/src/Node/Inline/RawHtmlInlineNode.php';
require '/path/to/src/Node/Inline/FootnoteRefNode.php';
require '/path/to/src/Normalizer/NormalizerInterface.php';
require '/path/to/src/Normalizer/IcuNormalizer.php';
require '/path/to/src/Sanitizer/UrlValidator.php';
require '/path/to/src/Sanitizer/HtmlSanitizer.php';
require '/path/to/src/Lexer/TokenType.php';
require '/path/to/src/Lexer/Token.php';
require '/path/to/src/Lexer/TableCells.php';
require '/path/to/src/Lexer/Lexer.php';
require '/path/to/src/Parser/DelimiterRun.php';
require '/path/to/src/Parser/DelimiterEntry.php';
require '/path/to/src/Parser/FlankingComputer.php';
require '/path/to/src/Parser/DelimiterStack.php';
require '/path/to/src/Parser/InlineParser.php';
require '/path/to/src/Parser/Parser.php';
require '/path/to/src/Parser/FrontMatterParser.php';
require '/path/to/src/Renderer/HtmlRenderer.php';
require '/path/to/src/MarkdownParser.php';

Try it — Docker Playground

No PHP installation required. From the project root:

docker-compose up --build

Open http://localhost:8080 in your browser. See playground/README.md for full setup details, port-override instructions, and caveats.

Quick Start

<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';

use PhpMarkdown\MarkdownParser;

$parser = new MarkdownParser();
$html   = $parser->parse('# Hello **World**');

echo $html; // <h1>Hello <strong>World</strong></h1>

Front Matter

$markdown = <<<MD
---
title: My Post
published: true
tags: php
---

# My Post

Content here.
MD;

['html' => $html, 'meta' => $meta] = $parser->parseWithMeta($markdown);

echo $meta['title'];     // My Post
echo $meta['published']; // true (bool)
echo $html;              // <h1>My Post</h1>\n<p>Content here.</p>\n

parse() also accepts front matter — it strips the block silently, so existing code needs no changes.

GFM Tables

$markdown = <<<MD
| Name  | Score |
|:------|------:|
| Alice |    95 |
| Bob   |    87 |
MD;

echo $parser->parse($markdown);
// <table>
// <thead>
// <tr>
// <th align="left">Name</th>
// <th align="right">Score</th>
// </tr>
// </thead>
// <tbody>
// <tr>
// <td align="left">Alice</td>
// ...

Raw HTML pass-through

Raw HTML is escaped by default (XSS-safe). Opt in to sanitized pass-through with allowRawHtml: true:

$html = $parser->parse('<div class="note">text</div>', allowRawHtml: true);
// <div class="note">text</div>  — dangerous tags/attrs stripped by HtmlSanitizer

Options

All constructor arguments are optional and named:

$parser = new MarkdownParser(
    maxBytes: 1_048_576,   // default; larger input throws ParseException (checked before and after NFC normalization)
    linkTarget: '_blank',  // adds target="…" to every rendered link and autolink
    linkRel: 'nofollow',   // adds rel="…"; "noopener" is appended automatically when target is _blank
);

echo $parser->parse('[a](/x)');
// <p><a href="/x" target="_blank" rel="nofollow noopener">a</a></p>

A custom NormalizerInterface can also be passed as normalizer: (defaults to IcuNormalizer).

Supported Features

Block elements

Feature Syntax Output
Headings H1–H6 (ATX) # Heading … ###### Heading (empty content allowed: # ) <h1> … <h6>
Headings H1–H2 (Setext) Heading\n=== / Heading\n--- (multiline content supported) <h1> / <h2>
Paragraph plain text (soft line breaks preserved as newlines) <p>
Blockquote (nested) > text <blockquote>
Unordered list - item / * item / + item <ul><li>
Ordered list 1. item / 1) item (start number kept: 3. item) <ol><li> / <ol start="3">
Nested lists indented - item inside list item <ul> inside <li>
Task list - [x] done / - [ ] todo <li><input type="checkbox" …>
Fenced code block ```lang … ``` or ~~~lang … ~~~ <pre><code class="language-*">
Mermaid diagram ```mermaid … ``` <div class="mermaid">
Indented code block 4-space / 1-tab indent <pre><code>
Horizontal rule --- / *** / ___ <hr />
GFM Table | col | col | + separator row <table><thead><tbody> with align
Front Matter ---\nkey: value\n--- at file top parsed into meta array via parseWithMeta()
Raw HTML block <div>…</div> escaped (default) or sanitized (allowRawHtml: true)
Two-column layout :::columns … ||| … ::: <div class="grid grid-cols-2 …">
Footnote definition [^label]: body rendered in <section class="footnotes">

Inline elements

Feature Syntax Output
Bold **text** or __text__ <strong>
Italic *text* or _text_ <em>
Strikethrough ~~text~~ <del>
Inline code `code` <code>
Link (inline) [text](url) or [text](<url>), optional title in "…", '…' or (…) <a href="…">
Link (reference) [text][ref] with [ref]: url or [ref]: <url> definition <a href="…">
Image ![alt](src), ![alt](src "title") or ![alt][ref] <img src="…" alt="…" />
Autolink <https://…> or <user@example.com> <a href="…">
Hard line break two trailing spaces + newline <br />
HTML entity / numeric reference &copy;, &#42;, &#x2A; decoded to the character, then re-escaped
Backslash escape \*not emphasis\* literal punctuation
Raw HTML inline <span class="x"> escaped (default) or sanitized (allowRawHtml: true)
Footnote reference [^label] <sup><a href="#fn-…">

Architecture

The library is built as a three-stage pipeline. Each stage is independent and testable in isolation.

Input string
    │
    ▼
┌─────────┐   Token[]    ┌────────┐   DocumentNode   ┌──────────┐
│  Lexer  │ ──────────►  │ Parser │ ───────────────►  │ Renderer │ ──► HTML string
└─────────┘              └────────┘                   └──────────┘
  One-pass                Builds AST                   Stateless
  O(n)                    Block + Inline                traversal
Class Responsibility
Lexer Tokenises the input line by line into Token[] (tabs expanded to 4-column stops, CommonMark §2.1)
InlineParser Recursive delimiter-stack scanner for inline elements
Parser Consumes Token[] and builds a DocumentNode AST
HtmlRenderer Traverses the AST and emits escaped HTML; void elements are self-closed (<br />, <hr />, <img … />) as in the CommonMark spec
HtmlSanitizer DOM-based sanitizer used when allowRawHtml: true
FrontMatterParser Extracts and parses the YAML-subset front matter block
IcuNormalizer NFC-normalizes input via PHP intl if available
MarkdownParser Public façade — wires all stages together

All AST nodes are immutable (readonly properties, PHP 8.2+).

Security

All user-supplied content is escaped via htmlspecialchars(ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') before being written to output. This covers:

  • Text content (all TextNode values)
  • Code content (CodeNode, FencedCodeNode)
  • HTML attributes (href, src, alt, title, class)

Dangerous URL schemes (javascript:, vbscript:, data:) in links are detected at parse time and rendered as literal text rather than <a> elements.

Raw HTML is escaped by default. Passing allowRawHtml: true enables DOM-based sanitization via HtmlSanitizer:

  • Forbidden tags (script, iframe, form, etc.) are removed with their entire subtree.
  • Unknown tags are unwrapped (children promoted).
  • All on* event handlers, style, and javascript:/data: URL attributes are stripped.
  • target="_blank" links get rel="noopener" injected automatically.

Resource limits: input is limited to maxBytes (1 MiB by default). Short table rows are completed with empty cells up to 100,000 cells per document; past that, they stay short. Parse time stays roughly linear in input size on pathological input (tests/Security). Memory use is high at the limit: a 1 MiB document needed from about 100 MB to 800 MB in stress tests, depending on its content, so use a lower maxBytes with PHP's default memory_limit of 128 MB.

Testing

# PHPUnit (requires dev dependencies)
composer install
php vendor/bin/phpunit --no-coverage

# Standalone (no PHPUnit needed)
php test.php

Benchmark

benchmark/ compares this library with league/commonmark, Parsedown and michelf/php-markdown on the same generated document (headings, emphasis, links, lists, fenced code, blockquote, table) in three sizes. It installs the library from the parent directory as a Composer path repository, so it measures your working copy.

cd benchmark
composer install   # copies the parent directory into vendor/ — re-run after changing src/
php benchmark-markdown.php

Sample results (PHP 8.2.29, Windows x64, no OPcache/JIT, best time per parser over 6 runs), lower is better:

Document php-markdown/parser Parsedown michelf/php-markdown league/commonmark
small (1 KB, 1,000 runs) 0.57 ms 0.38 ms 0.45 ms 2.01 ms
medium (10 KB, 200 runs) 4.30 ms 4.75 ms 4.72 ms 14.47 ms
large (82 KB, 20 runs) 35.73 ms 32.61 ms 37.05 ms 117.58 ms

Timings vary by machine and between runs. On the small document, the order was the same in every run. On the medium and large documents, the three fastest parsers swapped places from run to run, so their differences there are within the noise. The memory column printed by the script is the memory_get_usage() delta across the loop, not peak usage, so it is not reported here.

Limitations

The following are not supported:

  • LaTeX / math ($…$, $$…$$)
  • Definition lists
  • Custom HTML attributes in Markdown syntax

License

MIT © 2026