markdown-office / commonmark-generic-directives
A standalone league/commonmark extension implementing the Generic Directives RFC: container (:::), leaf (::) and text (:) directives with attribute, #id and .class syntax.
Package info
github.com/markdown-office/commonmark-generic-directives
pkg:composer/markdown-office/commonmark-generic-directives
Requires
- php: ^8.2
- league/commonmark: ^2.10
Requires (Dev)
- pestphp/pest: ^3.0 || ^4.0 || ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-27 19:30:53 UTC
README
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>
Installation
composer require markdown-office/commonmark-generic-directives
Requires PHP 8.2 or newer and league/commonmark 2.10 or newer.
Usage
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.
Writing your own renderer
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);
The three shapes
| 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>
Attributes
{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>
Unknown names: permissive or strict
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.
The AST
$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.
What is deliberately not a directive
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.
Where an author's attributes land
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.
The namespace is an option
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.
Tests
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, tests
The 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.
Licence
MIT. See LICENSE.