Markdown
Lightweight Markdown parser with no external dependencies. Converts Markdown text (or a .md file) into an array of AbstractBlock objects, usable directly in Twig templates via the markdown_blocks() function and the md_inline filter.
Module Structure
Tools/Markdown/
├── MarkdownManager.php # Main parser
├── MarkdownModule.php # Registration in the DI container
├── Block/
│ ├── AbstractBlock.php # Common base class
│ ├── HeadingBlock.php
│ ├── ParagraphBlock.php
│ ├── CodeBlock.php
│ ├── ListBlock.php
│ ├── TableBlock.php
│ ├── QuoteBlock.php
│ └── HrBlock.php
├── Extension/
│ └── MarkdownViewExtension.php # Twig function and filter
└── Exception/
└── MarkdownException.php # Specific exception
Block Hierarchy
Folder: Block/
MarkdownManager::parse() (and therefore blocks() / parseFile()) no longer returns generic associative arrays, but a list of typed objects, each inheriting from AbstractBlock. Each block type has its own shape (readonly properties), which brings autocompletion and static checking on the PHP side — while remaining transparent on the Twig side, where block.type, block.text, etc. continue to work exactly as before (Twig reads an array key or a public property indifferently).
AbstractBlock
Abstract class common to all blocks. Carries only the type property, set by each subclass.
abstract class AbstractBlock
{
public function __construct(
public readonly string $type,
) {}
}
HeadingBlock
Heading (# to ######).
public readonly int $level; // 1 to 6
public readonly string $text;
public readonly string $slug; // For HTML anchors
ParagraphBlock
Text paragraph.
public readonly string $text;
CodeBlock
Code block delimited by triple backtick fences (```).
public readonly string $language; // Empty if not specified
public readonly string $content;
ListBlock
Bulleted or numbered list.
public readonly bool $ordered; // true for 1. 2. 3. ...
public readonly array $items; // list<string>
TableBlock
Markdown table.
public readonly array $header; // list<string>
public readonly array $rows; // list<list<string>>
QuoteBlock
Quote (>).
public readonly string $content; // Lines joined by \n
HrBlock
Horizontal separator (---, ***, ___). Carries no data beyond type.
MarkdownManager
File: MarkdownManager.php
Entry point of the parser. Accepts both a raw Markdown string and a .md file path. Results are cached in memory (based on the resolved path and the file's filemtime).
blocks() Method
public function blocks(string $input): array // list<AbstractBlock>
Main method. Its behavior depends on $input:
- If
$inputends with.mdor.markdown(with no line break), it is treated as a file path. - The file is looked up as an absolute path, then relative to
ROOT_DIRorpublicPath. - If the file is not found, a
MarkdownException(code 404) is thrown with a diagnostic indicating the missing segment in the tree. - Otherwise,
$inputis parsed directly as Markdown content.
$manager = $container->get(MarkdownManager::class);
// From a file
$blocks = $manager->blocks('neo/Core/Asset/README.md');
// From a string
$blocks = $manager->blocks("# Title\n\nA paragraph.");
foreach ($blocks as $block) {
if ($block instanceof HeadingBlock) {
echo $block->text;
}
}
parse() Method
public function parse(string $markdown): array // list<AbstractBlock>
Parses a Markdown string and returns the list of AbstractBlock objects. Processes blocks in order: CodeBlock, HeadingBlock, HrBlock, QuoteBlock, TableBlock, ListBlock, ParagraphBlock.
$blocks = $manager->parse("## Title\n\nContent.");
parseFile() Method
public function parseFile(string $path): array // list<AbstractBlock>
Resolves the file path (absolute or relative to the project root), reads its content, and calls parse(). The result is cached with the key {resolved_path}:{filemtime} to avoid unnecessary re-parsing.
Throws a MarkdownException (code 404) if the file cannot be found, or (code 500) if it cannot be read.
renderInline() Method
public function renderInline(string $text): string
Applies inline formatting to a text: first escapes HTML via htmlspecialchars, then converts inline Markdown syntax into HTML tags.
| Markdown Syntax | HTML Result |
|---|---|
'''code''' | <code>code</code> |
![alt] (url) | <img src="url" alt="alt"> |
[text] (url) | <a href="url">text</a> |
bold or bold | <strong>bold</strong> |
italic or italic | <em>italic</em> |
MarkdownViewExtension
File: Extension/MarkdownViewExtension.php
Twig extension automatically registered via the #[Extension(type: ExtensionTypeEnum::VIEW)] attribute. It exposes a Twig function and filter. No change related to the block hierarchy: it simply delegates to MarkdownManager, which now returns objects instead of arrays.
Twig Function markdown_blocks()
Calls MarkdownManager::blocks() from a Twig template.
{# From a .md file #}
{% set blocks = markdown_blocks('path/to/file.md') %}
{# From a variable containing raw Markdown #}
{% set blocks = markdown_blocks(article.content) %}
Twig Filter md_inline
Applies MarkdownManager::renderInline() to a string. Marked is_safe: html, the result is not re-escaped by Twig.
{# In a template #}
<p>{{ block.text|md_inline|raw }}</p>
<li>{{ item|md_inline|raw }}</li>
Note: The filter already produces escaped HTML (via
htmlspecialchars). Using|rawis only necessary if the Twig config hasauto_escapeenabled — which is the default in NeoPHP.
Rendering Template
NeoPHP includes a ready-to-use Twig template to display a full Markdown document.
File: src/<Project>/Templates/markdown/document.html.twig
{% include 'markdown/document.html.twig'
with { blocks: markdown_blocks('neo/Core/Asset/README.md') } %}
This template iterates over the blocks and generates the corresponding HTML for each type (heading, paragraph, code, list, table, quote, hr). It wraps everything in <div class="markdown-body">.
Since Twig accesses array keys and an object's public properties indifferently, this template did not need to be modified after the switch to AbstractBlock objects — block.type, block.text, block.level, etc. continue to work identically.
Custom display without the template:
{% for block in markdown_blocks('docs/guide.md') %}
{% if block.type == 'heading' %}
<h{{ block.level }}>{{ block.text|md_inline|raw }}</h{{ block.level }}>
{% elseif block.type == 'paragraph' %}
<p>{{ block.text|md_inline|raw }}</p>
{% elseif block.type == 'code' %}
<pre><code class="language-{{ block.language }}">{{ block.content }}</code></pre>
{% endif %}
{% endfor %}
MarkdownModule
File: MarkdownModule.php
Registers MarkdownManager as a singleton service in the DI container. Declares ViewModule as a dependency so that MarkdownViewExtension can be injected into Twig.
// Direct resolution from the container
$manager = $container->get(MarkdownManager::class);
MarkdownException
File: Exception/MarkdownException.php
Extends FrameworkException. Thrown in two cases:
| Code | Cause |
|---|---|
404 | Markdown file not found. The message includes a diagnostic of the missing segment in the tree. |
500 | File found but unreadable (file_get_contents failed). |
try {
$blocks = $manager->blocks('docs/missing.md');
} catch (\Neo\Core\Tools\Markdown\Exception\MarkdownException $e) {
// $e->getCode() === 404
// $e->getMessage() contains the resolved path and the first missing segment
}