develate / commonmark-customtags
A commonmark extension for custom tags
Requires
- php: ^8.0
- league/commonmark: ^2.0
README
A league/commonmark v2 extension for inline custom tags such as:
Hello {{badge text="Ready now" tone=success}}
The extension parses the content between the delimiters, uses the first token as a tag identifier, supports quoted argument values, URL-decodes the remaining arguments, and delegates rendering to your own Customtag classes.
Installation
composer require develate/commonmark-customtags
Requirements:
- PHP 8.0 or newer
league/commonmark2.x
Basic Usage
Register the extension on a CommonMark environment and add one or more tags:
<?php require __DIR__ . '/vendor/autoload.php'; use Develate\CommonmarkCustomtags\Customtag; use Develate\CommonmarkCustomtags\CustomtagExtension; use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\MarkdownConverter; final class BadgeTag extends Customtag { public function identifier(): string { return 'badge'; } public function render($arguments, $globals): Stringable|string|null { $text = htmlspecialchars($arguments['text'] ?? '', ENT_QUOTES); $tone = htmlspecialchars($arguments['tone'] ?? 'default', ENT_QUOTES); return sprintf('<span class="badge badge--%s">%s</span>', $tone, $text); } } $environment = new Environment([]); $environment->addExtension(new CommonMarkCoreExtension()); $environment->addExtension( (new CustomtagExtension()) ->addTag(new BadgeTag()) ); $converter = new MarkdownConverter($environment); echo $converter->convert('Status: {{badge text="Ready now" tone=success}}');
Output:
<p>Status: <span class="badge badge--success">Ready now</span></p>
Tag Syntax
The default delimiters are {{ and }}.
{{identifier argument key=value another="hello world"}}
Inside the delimiters:
- The first token is the tag identifier.
- Remaining tokens become arguments.
- Tokens are separated by spaces unless wrapped in double quotes.
key=valuetokens become named arguments.- Tokens without
=become positional arguments. - Argument values can be wrapped in double quotes when they contain spaces.
- Argument values are still decoded with
urldecode(), so existing%20or+inputs keep working. - Use
%22inside quoted values when you need a literal double quote.
For example:
{{button "Visit site" href=https%3A%2F%2Fexample.com style=primary}}
is passed to the matching tag as:
[
0 => 'Visit site',
'href' => 'https://example.com',
'style' => 'primary',
]
If a tag identifier is not registered, the inner text is rendered unchanged and the delimiters are removed.
Custom tags are intended for plain command-style text. Put the tag name first, provide at least one token, and wrap argument values that contain spaces in double quotes. URL-encoded values are still supported for compatibility.
Creating Tags
Create a class that extends Develate\CommonmarkCustomtags\Customtag.
use Develate\CommonmarkCustomtags\Customtag; final class MentionTag extends Customtag { public function identifier(): string { return 'mention'; } public function render($arguments, $globals): Stringable|string|null { $username = htmlspecialchars($arguments[0] ?? '', ENT_QUOTES); return '<a href="/users/' . $username . '">@' . $username . '</a>'; } }
Register it with the extension:
$extension = new CustomtagExtension(); $extension->addTag(new MentionTag()); $environment->addExtension($extension);
Then use it in Markdown:
Thanks {{mention thorsten}} for the update.
Normalizing Arguments
Override makeArguments() when a tag should validate or normalize its arguments before rendering:
final class AlertTag extends Customtag { public function identifier(): string { return 'alert'; } public function makeArguments(array $arguments): array { return [ 'type' => $arguments['type'] ?? 'info', 'message' => $arguments['message'] ?? '', ]; } public function render($arguments, $globals): Stringable|string|null { $type = htmlspecialchars($arguments['type'], ENT_QUOTES); $message = htmlspecialchars($arguments['message'], ENT_QUOTES); return '<aside class="alert alert--' . $type . '">' . $message . '</aside>'; } }
Markdown:
{{alert type=warning message="Check your settings"}}
Shared Globals
Pass shared context into the extension constructor. The same value is provided to every tag's render() method as $globals.
$extension = new CustomtagExtension( globals: [ 'asset_base_url' => 'https://cdn.example.com', 'locale' => 'en', ], );
Use it inside a tag:
public function render($arguments, $globals): Stringable|string|null { $src = rtrim($globals['asset_base_url'], '/') . '/' . ltrim($arguments['src'] ?? '', '/'); return '<img src="' . htmlspecialchars($src, ENT_QUOTES) . '" alt="">'; }
$globals can be an array, object, service container, or any other value your tags know how to consume.
Custom Delimiters
You can change the opening and closing delimiters:
$extension = new CustomtagExtension( globals: null, tags: [], openingDelimiter: '[[', closingDelimiter: ']]', ); $extension->addTag(new BadgeTag());
Markdown:
[[badge text=New]]
Delimiter rules:
- Opening and closing delimiters must have the same length.
- Each delimiter must be made by repeating one character.
- Valid examples:
{{and}},[[and]],((and)),%%and%%. - Invalid examples:
<%and%>,{%and%}.
These rules come from the way league/commonmark delimiter processors match character runs.
Registering Tags in the Constructor
Tags can also be passed directly to the constructor. The array keys must be the tag identifiers:
$extension = new CustomtagExtension( tags: [ 'badge' => new BadgeTag(), 'mention' => new MentionTag(), ], );
Using addTag() is usually simpler because it keys the tag by identifier() for you.
Escaping and Security
Rendered tag output is returned directly to CommonMark. Escape any user-controlled values yourself before returning HTML.
Recommended helpers:
htmlspecialchars($value, ENT_QUOTES) urlencode($value)
If a tag should not render anything, return null or an empty string from render().
Full Example
<?php require __DIR__ . '/vendor/autoload.php'; use Develate\CommonmarkCustomtags\Customtag; use Develate\CommonmarkCustomtags\CustomtagExtension; use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\MarkdownConverter; final class LinkButtonTag extends Customtag { public function identifier(): string { return 'link-button'; } public function makeArguments(array $arguments): array { return [ 'label' => $arguments[0] ?? $arguments['label'] ?? 'Open', 'href' => $arguments['href'] ?? '#', 'variant' => $arguments['variant'] ?? 'primary', ]; } public function render($arguments, $globals): Stringable|string|null { $label = htmlspecialchars($arguments['label'], ENT_QUOTES); $href = htmlspecialchars($arguments['href'], ENT_QUOTES); $variant = htmlspecialchars($arguments['variant'], ENT_QUOTES); return '<a class="button button--' . $variant . '" href="' . $href . '">' . $label . '</a>'; } } $environment = new Environment([]); $environment->addExtension(new CommonMarkCoreExtension()); $customtags = new CustomtagExtension(); $customtags->addTag(new LinkButtonTag()); $environment->addExtension($customtags); $converter = new MarkdownConverter($environment); echo $converter->convert( 'Read more: {{link-button Documentation href=https%3A%2F%2Fcommonmark.thephpleague.com variant=secondary}}' );