Skip to content

Commit c901290

Browse files
authored
Merge pull request #215 from dotkernel/issue-208
How to use Twig Markdown to generate HTML post
2 parents 348924a + 02cfaf8 commit c901290

4 files changed

Lines changed: 197 additions & 0 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
---
2+
title: "How to Use Twig Markdown to Generate HTML Pages at Runtime"
3+
description: "How to wire twig/markdown-extra with league/commonmark so a Twig application converts Markdown files to HTML on every request, instead of pre-compiling pages at build time."
4+
author: "stefan"
5+
date_published: "2026-09-09"
6+
canonical_url: "https://www.dotkernel.com/how-to/how-to-use-twig-markdown-to-generate-html-pages-at-runtime/"
7+
category: "How to's"
8+
language: "en"
9+
---
10+
11+
# How to Use Twig Markdown to Generate HTML Pages at Runtime
12+
13+
## TL;DR
14+
15+
`twig/markdown-extra` adds a `markdown_to_html` filter to Twig but ships no Markdown parser itself — you provide one through a `RuntimeLoaderInterface` factory.
16+
Wire `MarkdownExtension` and a `MarkdownRuntime` (backed here by `league/commonmark`'s `GithubFlavoredMarkdownConverter`) into your container, register the extension with Twig, then call `{{ someMarkdownString|markdown_to_html }}` in any template.
17+
Because the conversion runs inside the request/response cycle rather than at build time, the source `.md` file can be read from disk, edited, or swapped right up until the moment a request asks for it — no build step, no cache to invalidate by hand.
18+
19+
## Why Runtime Instead of Build Time
20+
21+
A static-site generator turns Markdown into HTML once, ahead of time, and serves the resulting files.
22+
That works well when content changes rarely and a build pipeline is already part of the deploy.
23+
Rendering at runtime is a better fit when:
24+
25+
- Content lives in the same repository as the code and should go live the moment the file is merged, with no separate "build the site" step.
26+
- The same Markdown source needs to serve more than one output — an HTML page, a raw `text/markdown` response for a bot or an LLM, an RSS/Atom feed excerpt — without maintaining a pre-rendered copy for each.
27+
- A handler needs to inspect or transform the Markdown first (strip a section, extract structured data from it) before deciding what HTML to produce.
28+
29+
The tradeoff is that every request pays the parsing cost, so pair it with an opcode cache and, if traffic warrants it, an HTTP or application cache layer — the parsing itself is not the expensive part until a page is read thousands of times a minute.
30+
31+
## Step 1: Install the Packages
32+
33+
Two packages are required: the Twig extension that exposes the filter, and a Markdown engine to actually do the conversion.
34+
35+
```bash
36+
composer require twig/markdown-extra league/commonmark
37+
```
38+
39+
`twig/markdown-extra` is deliberately engine-agnostic — it defines the `markdown_to_html` filter and a `MarkdownInterface` contract, but leaves the choice of parser (CommonMark, Parsedown, michelf/php-markdown, or a custom one) to the application.
40+
41+
## Step 2: Register the Extension and a Runtime Loader
42+
43+
Twig extensions that need constructor arguments, or that wrap a heavier dependency, are usually built through the DI container rather than instantiated inline. Two pieces go into the container: the extension itself, and a `RuntimeLoaderInterface` that lazily builds the `MarkdownRuntime` the extension calls at render time.
44+
45+
```php
46+
// MarkdownExtensionFactory.php
47+
use Psr\Container\ContainerInterface;
48+
use Twig\Extra\Markdown\MarkdownExtension;
49+
50+
class MarkdownExtensionFactory
51+
{
52+
public function __invoke(ContainerInterface $container): MarkdownExtension
53+
{
54+
return new MarkdownExtension();
55+
}
56+
}
57+
```
58+
59+
```php
60+
// MarkdownRuntimeLoaderFactory.php
61+
use League\CommonMark\GithubFlavoredMarkdownConverter;
62+
use Psr\Container\ContainerInterface;
63+
use Twig\Extra\Markdown\LeagueMarkdown;
64+
use Twig\Extra\Markdown\MarkdownRuntime;
65+
use Twig\RuntimeLoader\FactoryRuntimeLoader;
66+
use Twig\RuntimeLoader\RuntimeLoaderInterface;
67+
68+
class MarkdownRuntimeLoaderFactory
69+
{
70+
public function __invoke(ContainerInterface $container): RuntimeLoaderInterface
71+
{
72+
return new FactoryRuntimeLoader([
73+
MarkdownRuntime::class => static fn (): MarkdownRuntime => new MarkdownRuntime(
74+
new LeagueMarkdown(new GithubFlavoredMarkdownConverter())
75+
),
76+
]);
77+
}
78+
}
79+
```
80+
81+
`LeagueMarkdown` is the small adapter `twig/markdown-extra` ships for `league/commonmark`; swapping engines later only means writing a different adapter (or using one of the built-in ones) inside this one factory — nothing in the templates has to change.
82+
83+
Then register both in the Twig configuration, so the extension is loaded and the runtime loader is available to resolve it:
84+
85+
```php
86+
'twig' => [
87+
'extensions' => [
88+
MarkdownExtension::class,
89+
],
90+
'runtime_loaders' => [
91+
RuntimeLoaderInterface::class,
92+
],
93+
],
94+
```
95+
96+
And map both classes to their factories in the container configuration:
97+
98+
```php
99+
'factories' => [
100+
MarkdownExtension::class => MarkdownExtensionFactory::class,
101+
RuntimeLoaderInterface::class => MarkdownRuntimeLoaderFactory::class,
102+
],
103+
```
104+
105+
## Step 3: Convert Markdown to HTML in a Template
106+
107+
With both pieces registered, any string of Markdown passed into a template can be converted with the filter:
108+
109+
```twig
110+
{{ content|markdown_to_html }}
111+
```
112+
113+
`content` here is just a plain PHP string — commonly the body of a `.md` file read with `file_get_contents()` and handed to the template as a render variable. The filter runs CommonMark against it and outputs the resulting HTML, unescaped, directly into the page.
114+
115+
## Step 4: Resolve the Source File Safely
116+
117+
If the Markdown file path is built from anything in the request — a slug, a category, a page name in the URL — resolve it with `realpath()` and verify the result still lives inside the intended base directory before reading it. Otherwise a crafted slug like `../../../../etc/passwd` could walk outside the content directory:
118+
119+
```php
120+
public function resolveMarkdownFilePath(string $categorySlug, string $slug): ?string
121+
{
122+
$base = realpath($this->articlesPath);
123+
if ($base === false) {
124+
return null;
125+
}
126+
127+
$realPath = realpath(rtrim($base, '/') . '/' . $categorySlug . '/' . $slug . '.md');
128+
if ($realPath === false || ! is_file($realPath)) {
129+
return null;
130+
}
131+
132+
if (! str_starts_with($realPath, rtrim($base, '/') . '/')) {
133+
return null;
134+
}
135+
136+
return $realPath;
137+
}
138+
```
139+
140+
`realpath()` returns `false` for a path that doesn't exist and also collapses any `..` segments, so the prefix check afterwards is what actually rejects a resolved path that escaped the articles directory — checking the raw, unresolved string wouldn't be enough on its own.
141+
142+
## Putting It Together in a Handler
143+
144+
A typical request handler ties the four steps into one flow: resolve the file, read it, optionally pull out metadata (front matter, an FAQ section, anything else the page needs), and hand the remaining body to the template that applies the filter.
145+
146+
```php
147+
$markdownFile = $this->blogService->resolveMarkdownFilePath($categorySlug, $slug);
148+
if ($markdownFile === null) {
149+
return $this->notFound();
150+
}
151+
152+
$body = file_get_contents($markdownFile);
153+
154+
return new HtmlResponse(
155+
$this->template->render('page::article', ['content' => $body])
156+
);
157+
```
158+
159+
Nothing here writes an HTML file to disk. The `.md` file is the only artifact that exists at rest; the HTML is produced fresh on every request and thrown away as soon as the response is sent.
160+
161+
## Resources
162+
163+
- [twig/markdown-extra documentation](https://twig.symfony.com/doc/3.x/filters/markdown_to_html.html) — the official filter reference
164+
- [league/commonmark documentation](https://commonmark.thephpleague.com/) — the CommonMark/GFM converter used in the examples above
165+
- [CommonMark specification](https://spec.commonmark.org/) — the Markdown dialect both packages implement
166+
167+
## FAQ
168+
169+
**Q: Does twig/markdown-extra include its own Markdown parser?**
170+
A: No. It only defines the `markdown_to_html` filter and a `MarkdownInterface` contract; you must register a `RuntimeLoaderInterface` factory that builds a `MarkdownRuntime` backed by an actual engine, such as league/commonmark, Parsedown, or michelf/php-markdown.
171+
172+
**Q: Why render Markdown at runtime instead of pre-building HTML files?**
173+
A: Runtime rendering lets content go live the moment a file is merged, with no separate build step, and lets the same source serve more than one output (HTML, raw text/markdown, feed excerpts) from a single file.
174+
175+
**Q: Is converting Markdown to HTML on every request expensive?**
176+
A: Parsing itself is fast; the practical cost only shows up at high traffic. An opcode cache plus, if needed, an HTTP or application-level cache in front of the rendered response is enough to handle most load without pre-building anything.
177+
178+
**Q: How do you prevent a URL from reading a file outside the content directory?**
179+
A: Resolve the constructed path with `realpath()`, which returns `false` for paths that don't exist and collapses any `..` segments, then verify the resolved path still starts with the base content directory before reading the file.
180+
181+
**Q: Can the Markdown engine be swapped later without touching templates?**
182+
A: Yes. The engine is only referenced inside the `RuntimeLoaderInterface` factory; templates only ever call the `markdown_to_html` filter, so replacing `LeagueMarkdown` with a different adapter is a one-file change.
253 KB
Loading
253 KB
Loading

‎src/App/src/Fixture/articles_cleaned.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,6 +2010,21 @@
20102010
"excerpt": "With the recent release of AlmaLinux OS 10, we have created a new recipe for our WSL development environment. Compared to its predecessor, AlmaLinux 10 provides performance enhancements, security updates and improved hardware support.",
20112011
"tl_dr": "With the release of AlmaLinux OS 10, Dotkernel created a new WSL2 development environment recipe offering performance, security, and hardware improvements over AlmaLinux 9.\nThe recipe sets up WSL2, AlmaLinux 10, PHP, Apache, MariaDB, Git, Composer, Node.js, and PhpMyAdmin.\nIt also covers the OS/hardware requirements, installing the distro, and running PHP projects directly or via virtual hosts.",
20122012
"tags": []
2013+
},
2014+
{
2015+
"post_title": "How to Use Twig Markdown to Generate HTML Pages at Runtime",
2016+
"post_date": "2026-09-09 00:00:00",
2017+
"post_status": "published",
2018+
"author": {
2019+
"display_name": "stefan",
2020+
"github": "OStefan2001"
2021+
},
2022+
"isObsolete": false,
2023+
"isTwig": false,
2024+
"opengraph_img": "/opengraph/article/twitter-card-markdown-to-html.png",
2025+
"excerpt": "How to wire twig/markdown-extra with league/commonmark so a Twig application converts Markdown files to HTML on every request, instead of pre-compiling pages at build time.",
2026+
"tl_dr": "twig/markdown-extra adds a markdown_to_html filter to Twig but ships no Markdown parser itself — you provide one through a RuntimeLoaderInterface factory.\nWire MarkdownExtension and a MarkdownRuntime (backed by league/commonmark's GithubFlavoredMarkdownConverter) into your container, register the extension with Twig, then call {{ someMarkdownString|markdown_to_html }} in any template.\nBecause the conversion runs inside the request/response cycle rather than at build time, the source .md file can be read from disk, edited, or swapped right up until the moment a request asks for it — no build step, no cache to invalidate by hand.",
2027+
"tags": []
20132028
}
20142029
]
20152030
},

0 commit comments

Comments
 (0)