ows/commonmark-sup-sub-extensions
Add superscript and subscript support to your CommonMark markdown with this lightweight PHP extension. It registers ^text^ and text style syntax so you can render scientific notation, footnotes, and chemical formulas cleanly in Laravel or any CommonMark-powered app.
1. Install and check the version constraint first
composer require ows/commonmark-sup-sub-extensions
composer show ows/commonmark-sup-sub-extensions
composer show league/commonmark
This package is a thin wrapper over league/commonmark inline parsing, so the only thing that matters up front is which major version of league/commonmark it targets. ^2.0 uses EnvironmentBuilderInterface / ConfigurableExtensionInterface; ^1.x uses ConfigurableEnvironmentInterface. Mismatched majors manifest as a fatal TypeError or "class not found" the moment you register the extension — check composer.json's require block before writing any integration code.
2. Look at src/ before the README
Ten stars and an unknown repo means the README is not the contract — the source is. Spend two minutes confirming:
Ows\CommonMark\Sup\SupExtension and a sibling SubExtension, or one combined class),^…^ for sup, ~…~ for sub is the near-universal convention),HtmlElement nodes (this decides how you customise output later).3. Smallest possible conversion
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\MarkdownConverter;
use Ows\CommonMark\Sup\SupExtension; // verify FQCN in src/
use Ows\CommonMark\Sub\SubExtension;
$environment = new Environment([
'html_input' => 'escape',
'allow_unsafe_links' => false,
]);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new SupExtension());
$environment->addExtension(new SubExtension());
$converter = new MarkdownConverter($environment);
echo $converter->convert('H~2~O and 2^32^'); // H<sub>2</sub>O and 2<sup>32</sup>
First real use case: chemistry and math notation in user- or editor-authored content — H~2~SO~4~, CO~2~, 10^-9^, x^2^. It's a two-character win over typing raw HTML, and it keeps stored content portable (see the storage pattern below).
Building an Environment is the expensive part. Never do it per-render inside a loop or a Blade partial.
// config/services.php-ish / bootstrap
$container->singleton(MarkdownConverter::class, function () {
$environment = new Environment([
'html_input' => 'escape',
'allow_unsafe_links' => false,
'max_nesting_level' => 20,
]);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new SupExtension());
$environment->addExtension(new SubExtension());
// ... your other extensions, in a deliberate order
return new MarkdownConverter($environment);
});
laravel-markdownIf the app already uses graham-campbell/laravel-markdown, do not build a second environment. Publish config/markdown.php and append to the extensions array — that array takes extension class-strings and is the supported extension point:
// config/markdown.php
'extensions' => [
Ows\CommonMark\Sup\SupExtension::class,
Ows\CommonMark\Sub\SubExtension::class,
],
Then Markdown::render($text) picks them up everywhere, and you inherit its Blade directive, Str::markdown() macro, and any sanitisation config for free.
The best reason to use delimiters instead of inline <sup> HTML is portability: the stored source stays readable and re-renderable if you later swap renderers or tighten the sanitiser.
// write
$post->body = $request->validated('body'); // raw markdown, incl. H~2~O
// read
$html = Cache::remember("post.html.{$post->id}.v{$post->updated_at->timestamp}", 3600,
fn () => Markdown::render($post->body)->getContent());
Bump a version key when you change the environment (adding an extension, changing html_input) or stale HTML survives the deploy.
The sup/sub nodes are ordinary inline nodes, so you can re-render them with your own NodeRendererInterface and win specificity by priority:
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
final class SupRenderer implements NodeRendererInterface
{
public function render(Node $node, ChildNodeRendererInterface $childRenderer): HtmlElement
{
return new HtmlElement(
'sup',
['class' => 'notation-sup'],
$childRenderer->renderNodes($node->children())
);
}
}
$environment->addRenderer(Sup::class, new SupRenderer(), 10); // priority > 0
Reach for this rather than subclassing the extension: it survives package updates, and it keeps your markup changes (Tailwind classes, aria-*, data-*) in one file.
Load order matters only where delimiter characters collide (see Gotchas). A safe, boring order:
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new GithubFlavoredMarkdownExtension()); // tables, autolinks, ~~strike~~
$environment->addExtension(new SupExtension());
$environment->addExtension(new SubExtension());
$environment->addExtension(new AttributesExtension()); // if you need {: .class }
If GFM strikethrough is enabled you almost certainly cannot also enable this package's subscript extension — resolve that deliberately, not by trial and error in production.
public function test_subscript_renders(): void
{
self::assertSame(
"<p>H<sub>2</sub>O</p>\n",
$this->converter()->convert('H~2~O')->getContent()
);
}
Pin these with assertSame on the exact string. Inline delimiter parsing is where edge cases hide (a^b^c, 2^32^,, escaped \~), and a one-line regression test per case is cheaper than discovering it in a rendered email.
Licence is the first thing to check, not the last. GPL-3.0 is copyleft and this is a library you link into your app. For a closed-source commercial Laravel product this is a genuine compliance question, not a footnote — get it cleared before it ships, or plan to reimplement. The delimiters are ~40 lines of DelimiterProcessorInterface; a clean-room implementation is often cheaper than a licence review.
Delimiter collision with ~~strikethrough~~. Both extensions want ~. league/commonmark's DelimiterProcessorCollection rejects two processors for the same opening character, so enabling the GFM strikethrough extension and this subscript extension together throws at environment build time — a hard failure, not a silent mis-render. If you need both, keep strikethrough and drop subscript, or patch the processor to require ~ while GFM owns ~~ only, and write tests for both.
Accidental matches in prose. ^ and ~ appear in shell snippets, tilde-home paths and regexes. ~/config won't match (no closing delimiter), but foo~bar~baz will silently become foo<sub>bar</sub>baz. Escape with a backslash (\~) and, if this is user content, say so in your editor help text rather than after the first bug report.
html_input is your real XSS boundary, not the extension. The extension only produces <sup>/<sub> around already-escaped inline content. Set 'html_input' => 'escape' (or 'strip') and 'allow_unsafe_links' => false explicitly — the defaults are permissive, and a custom renderer that concatenates raw strings instead of building HtmlElement nodes reintroduces the hole.
Reuse the Environment; rebuild after config changes. Per-request instantiation is the classic performance trap in Laravel apps that render many snippets. But the corollary bites too: if the environment is a singleton and you change extensions mid-request (feature flag, tenant config), later renders see the mutated parser. Build one environment per configuration, not one per request.
max_nesting_level will throw on pathological input. Deeply nested emphasis plus delimiters can blow the limit and raise an exception rather than degrading. Set it deliberately (10–20 is plenty for prose) and wrap rendering of untrusted content in a try/catch that falls back to plain escaped text.
Low-traffic packages go unmaintained quietly. Pin an exact version in composer.lock, and note the release date looks anomalous — verify tags actually exist on Packagist and that the tag matches the code you read. If the project stalls, the fallback is cheap: one DelimiterProcessorInterface per delimiter plus one small NodeRendererInterface, roughly an afternoon, and you own the licence.
Debugging tips. InvalidArgumentException: Cannot add two delimiter processors for the same character means a delimiter collision, not a bad install. TypeError in addExtension() means a league/commonmark major mismatch. When output looks wrong, dump $converter->convert($src) for a minimal snippet rather than the real document — delimiter bugs are almost always local to a few characters of context.
How can I help you explore Laravel packages today?