develate/commonmark-customtags

A commonmark extension for custom tags

Maintainers

Package info

github.com/develate/commonmark-customtags

pkg:composer/develate/commonmark-customtags

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.1 2026-05-14 17:32 UTC

This package is auto-updated.

Last update: 2026-05-14 17:33:20 UTC


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/commonmark 2.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=value tokens 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 %20 or + inputs keep working.
  • Use %22 inside 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}}'
);