A league/commonmark extension implementing the
Generic Directives RFC:
container (:::), leaf (::) and text (:) directives.
One syntax, three shapes, and nothing to register to make it parse.
Ship it :badge[PRODUCTION]{type=success} today.
::image{src="chart.png" width=400}
:::callout{type=info title="Hosting Advantage"}
Runs on shared hosting.
:::<p>Ship it <span data-directive="badge" type="success">PRODUCTION</span> today.</p>
<div data-directive="image" src="chart.png" width="400"></div>
<div data-directive="callout" type="info" title="Hosting Advantage"><p>Runs on shared hosting.</p></div>composer require markdown-office/commonmark-generic-directivesRequires PHP 8.2 or newer and league/commonmark 2.10 or newer.
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\MarkdownConverter;
use MarkdownOffice\CommonMark\Directive\GenericDirectiveExtension;
$environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new GenericDirectiveExtension());
$html = (new MarkdownConverter($environment))->convert($markdown);The default renderer reproduces rather than chooses: the element carries the directive's name, and the attributes are written under the names their author gave them. What a directive means is yours to decide — register your own renderer over it.
<span data-directive="badge" type="success">PRODUCTION</span>The container above is checked against the opening example; see Tests for how.
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use MarkdownOffice\CommonMark\Directive\Node\DirectiveBlock;
final class CalloutRenderer implements NodeRendererInterface
{
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
\assert($node instanceof DirectiveBlock);
$type = $node->getAttributes()->getString('type', 'note');
return sprintf(
'<aside class="callout callout--%s">%s</aside>',
htmlspecialchars($type, ENT_QUOTES),
$childRenderer->renderNodes($node->children())
);
}
}
$environment->addRenderer(DirectiveBlock::class, new CalloutRenderer(), 10);| Shape | Written | Contains | Name |
|---|---|---|---|
| Text | :name[label]{attrs} |
nothing — it sits in a paragraph | DirectiveInline |
| Leaf | ::name[label]{attrs} |
nothing — it is one line | DirectiveBlock |
| Container | :::name{attrs} … ::: |
any Markdown | DirectiveBlock |
The colon count is the whole difference. A container opens with three or more colons and closes on a line of at least as many, so a body can show a nested three-colon container by opening its own with four.
:::col{width=50%}
:::note
Nested containers keep their own fences.
:::
Back in the outer one.
:::<div data-directive="col" width="50%"><div data-directive="note"><p>Nested containers keep their own fences.</p></div>
<p>Back in the outer one.</p></div>The RFC lets the parts of a block directive be spaced out for readability — :: name,
:::eval [label] {.python}. An inline directive does not get that: : is how prose
introduces a clause.
A container may be written without a name, and then it is a div carrying the attributes that followed it.
::: {.myClass}
some markdown
:::<div class="myClass"><p>some markdown</p></div>The opening line may also end in colons, which mean nothing — as long as something else is on
the line, because ::: on its own is how a container closes.
:::::::::::: SPOILER ::::::::::::
We're going to spoil it in three
easy steps:
1. ready
2. steady
3. go
::::::::::::::::::::::::::::::::::<div data-directive="SPOILER"><p>We're going to spoil it in three
easy steps:</p>
<ol>
<li>ready</li>
<li>steady</li>
<li>go</li>
</ol></div>{width=50%} bare value, runs to whitespace
{title="a b c"} double-quoted, so spaces survive
{title='a "b" c'} single-quoted, so double quotes survive
{wide} no value at all — present and true
{#lead .terse} # sets id, . appends to class#id and .class may also be written bare after the name — :::note#lead.terse means
the same thing — and several .class entries accumulate rather than replace each other.
Values are read with typed accessors rather than by hand:
$attributes = $node->getAttributes();
$attributes->getString('title', 'Untitled'); // string|null
$attributes->getInt('width', 400); // int|null
$attributes->getFloat('ratio'); // float|null
$attributes->getBool('open', false); // bool
$attributes->getList('tags'); // list<string>
$attributes->has('title'); // bool
$attributes->toArray(); // array<string, string|true>With no registry the extension is permissive and parses any well-formed directive, which
is what the RFC describes. Given a registry it is strict, and an unregistered name raises
UnknownDirective naming the alternatives:
use MarkdownOffice\CommonMark\Directive\Directive\DirectiveRegistry;
$environment->addExtension(new GenericDirectiveExtension(
new DirectiveRegistry(['col', 'callout', 'badge:text'])
));
// :::columns → UnknownDirective:
// Unknown container directive "columns". Registered: badge:text, callout, col.Use strict mode when a machine writes the Markdown. The alternative to an error is a document that renders without the block it asked for and says nothing about why — which is the worst outcome available, because nothing in the output looks wrong.
A bare name allows every shape; name:type allows one. Names match case-insensitively,
because a document cannot be asked to care about case.
$node->getName(); // 'callout', or null for a container written without one
$node->getType(); // DirectiveType::Container | Leaf | Text
$node->getLabel(); // 'Chart', or null when there is none
$node->getAttributes(); // DirectiveAttributes
$node->isContainer(); // bool
(string) $node; // back to source form__toString() round-trips: ::image[Chart]{src="a.png"} in, the same string out.
These are left as literal text, which is the recoverable outcome:
Note: prose— a colon before a space3:2,10:30— a colon between digitsstatus:error— a word before the colon:::a— four spaces of indent, which is code in CommonMark- anything inside a fenced or indented code block
That last one is structural rather than a rule this package applies: CommonMark does not
look for new block starts inside a block that is not a container, so a ::: inside a
fence is code before this extension is consulted.
They are written under the names the author gave them:
A :badge[X]{type=success onclick="steal()"} b<p>A <span data-directive="badge" type="success">X</span> b</p>That is what the official Attributes extension writes for the same syntax, so a document that uses both means the same thing in both.
An event handler is dropped rather than written: an attribute whose name starts with on
is a script the moment it lands on a page that runs scripts. The rule is a prefix rather
than a list of names, so only goes with it too. So is an attribute whose name HTML does
not accept: 12 is not a name a browser reads as an attribute.
data-directive is reserved and carries the directive's real name; an author attribute
that would land on it is dropped rather than renamed. A consumer that wants real
attributes reads the node rather than this markup.
A document whose author is not trusted has a reason to want an author's attributes kept out of the page's own names:
use MarkdownOffice\CommonMark\Directive\AttributeNaming;
$environment->addExtension(new GenericDirectiveExtension(
naming: AttributeNaming::Namespaced,
));A :badge[X]{type=success onclick="steal()"} b<p>A <span data-directive="badge" data-type="success" data-onclick="steal()">X</span> b</p>That is not tidiness. An onclick written into a page that later runs scripts is a
script, and deciding which attribute names are dangerous by keeping a list means the list
is only ever correct until someone invents an event. Namespacing needs no list to
maintain.
composer test # the whole suite
composer test:unit # the value objects
composer test:feature # parsing and rendering, end to end
composer check # validate, audit, static analysis, testsThe suite is 100% covered and analysed at PHPStan level 9. Every example on this page
is executed by tests/Readme/ExamplesTest.php and every claim in the prose by
tests/Readme/ClaimsTest.php, so neither can drift from the code without the build failing.
MIT. See LICENSE.