Obsługa Markdown w PHP przy użyciu CommonMark 2.x

+ tworzenie własnego rozszerzenia @[tekst]{atrybuty}
Ten artykuł pokazuje:
- Jak działa architektura
league/commonmark - Jak skonfigurować parser w wersji 2.x
- Jak napisać własne rozszerzenie inline
- Jak dodać obsługę
.class,#idi dowolnych atrybutów - Na co uważać w kontekście bezpieczeństwa
1. Wprowadzenie
Biblioteka The PHP League CommonMark (league/commonmark) to nowoczesna implementacja specyfikacji Markdown w PHP, zgodna ze standardem CommonMark.
Wersja 2.x wprowadziła:
- nowy system środowisk (
Environment) - wyraźny podział na parsery i renderery
- rozszerzalną architekturę opartą o Extension API
- wsparcie dla GFM (GitHub Flavored Markdown)
2. Instalacja
composer require league/commonmark
3. Architektura CommonMark 2.x
Pipeline przetwarzania wygląda tak:
Markdown
↓
Block Parsers
↓
Inline Parsers
↓
AST (Abstract Syntax Tree)
↓
Renderery
↓
HTML
Kluczowe komponenty:
| Element | Odpowiedzialność |
|---|---|
Environment |
Rejestruje rozszerzenia |
Extension |
Dodaje parsery i renderery |
InlineParser |
Rozpoznaje składnię inline |
Node |
Reprezentuje element AST |
Renderer |
Generuje HTML |
4. Konfiguracja środowiska
Minimalna konfiguracja:
use League\CommonMark\Environment\Environment;
use League\CommonMark\MarkdownConverter;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
$environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension());
$converter = new MarkdownConverter($environment);
echo $converter->convert('# Hello');
5. Tworzenie własnego rozszerzenia inline
Załóżmy, że chcemy obsłużyć składnię:
@[tekst]{.class #id data-x=1}
która generuje:
<span class="class" id="id" data-x="1">tekst</span>
5.1 Tworzenie Node
namespace App\Markdown;
use League\CommonMark\Node\Inline\AbstractInline;
class SpanNode extends AbstractInline
{
public function __construct(
private string $content,
private array $attributes = []
) {}
public function getContent(): string
{
return $this->content;
}
public function getAttributes(): array
{
return $this->attributes;
}
}
5.2 Parser Inline
Najważniejsze elementy:
- regex dopasowujący składnię
getPriority()(ważne!)advanceBy()(konieczne w 2.x)
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
class SpanParser implements InlineParserInterface
{
public function getMatchDefinition(): InlineParserMatch
{
return InlineParserMatch::regex('@\[(.*?)\]\{(.*?)\}');
}
public function getPriority(): int
{
return 150;
}
public function parse(InlineParserContext $context): bool
{
$cursor = $context->getCursor();
$matches = $context->getMatches();
$text = $matches[1];
$attrString = $matches[2];
$attributes = $this->parseAttributes($attrString);
$node = new SpanNode($text, $attributes);
$context->getContainer()->appendChild($node);
$cursor->advanceBy(strlen($matches[0]));
return true;
}
private function parseAttributes(string $input): array
{
$attributes = [];
$classes = [];
preg_match_all('/
(\.[\w-]+)
|(\#[\w-]+)
|([\w-]+)=(".*?"|\'.*?\'|[^\s]+)
/x', $input, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
if (!empty($match[1])) {
$classes[] = substr($match[1], 1);
} elseif (!empty($match[2])) {
$attributes['id'] = substr($match[2], 1);
} elseif (!empty($match[3])) {
$attributes[$match[3]] = trim($match[4], '"\'');
}
}
if ($classes) {
$attributes['class'] = implode(' ', $classes);
}
return $attributes;
}
}
5.3 Renderer
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
class SpanRenderer implements NodeRendererInterface
{
public function render($node, ChildNodeRendererInterface $childRenderer)
{
return new HtmlElement(
'span',
$node->getAttributes(),
$node->getContent()
);
}
}
5.4 Rejestracja jako Extension
Zamiast rejestrować wszystko ręcznie, lepiej stworzyć pełne rozszerzenie:
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
class SpanExtension implements ExtensionInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addInlineParser(new SpanParser());
$environment->addRenderer(SpanNode::class, new SpanRenderer());
}
}
Rejestracja:
$environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new SpanExtension());
6. Bezpieczeństwo
Jeśli Markdown pochodzi od użytkownika:
- rozważ whitelistę atrybutów
- wyłącz
html_inputlub ustaw nastrip - rozważ użycie
DisallowedRawHtmlExtension
Przykład:
$config = [
'html_input' => 'strip',
'allow_unsafe_links' => false,
];
7. Wydajność
Regex inline jest wygodny, ale:
- może być wolniejszy przy dużych dokumentach
- może powodować backtracking
Dla systemów CMS warto rozważyć parser oparty o analizę znak po znaku zamiast regex.
8. Podsumowanie
CommonMark 2.x daje:
- modularną architekturę
- bezpieczne przetwarzanie Markdown
- pełną rozszerzalność
- możliwość tworzenia własnej składni inline i block
Dzięki temu możesz:
- tworzyć własne komponenty (
@[...]) - implementować DSL wewnątrz Markdown
- budować systemy dokumentacji
- rozszerzać GFM