A league/commonmark extension suite for Birdcar Flavored Markdown (BFM) — a superset of CommonMark and GFM that adds YAML front-matter, directive blocks, extended task lists, task modifiers, mentions, hashtags, metadata extraction, and document merging.
See the BFM spec for the full syntax definition.
This is a monorepo containing the core library and framework integration packages:
| Package | Description | Install |
|---|---|---|
birdcar/markdown-php |
Core library (this repo root) | composer require birdcar/markdown-php |
birdcar/markdown-laravel |
Laravel service provider, Str::bfm() macro, @bfmStyles directive |
composer require birdcar/markdown-laravel |
birdcar/markdown-filament |
Filament v4 BfmEditor, BfmTextColumn, BfmTextEntry |
composer require birdcar/markdown-filament |
- PHP 8.2+
- league/commonmark ^2.7
- symfony/yaml ^8.0
use Birdcar\Markdown\Environment\BfmEnvironmentFactory;
use League\CommonMark\MarkdownConverter;
$environment = BfmEnvironmentFactory::create();
$converter = new MarkdownConverter($environment);
echo $converter->convert(<<<'MD'
---
title: Sprint Planning
tags:
- engineering
---
- [>] Call the dentist //due:2025-03-01
- [!] File taxes //due:2025-04-15 //hard
- [x] Buy groceries
@callout type=warning title="Heads Up"
Don't forget to bring your **insurance card**.
@endcallout
Hey @sarah, can you review this? #urgent
MD);composer require birdcar/markdown-laravelZero config — the service provider auto-discovers and registers everything:
use Illuminate\Support\Str;
// Render BFM to HTML
$html = Str::bfm('- [>] Call dentist //due:2025-03-01');
// Inline rendering (no wrapping <p> tags)
$html = Str::inlineBfm('Hey @sarah');
// Access the configured converter directly
$converter = app(\League\CommonMark\MarkdownConverter::class);In Blade templates:
{{-- Include BFM default styles --}}
@bfmStyles
{{-- Render content --}}
<article class="prose">
{!! Str::bfm($post->body) !!}
</article>Publish the config to customize the render profile or bind resolver classes:
php artisan vendor:publish --tag=bfm-config// config/bfm.php
return [
'profile' => 'html', // 'html', 'email', or 'plain'
'resolvers' => [
'mention' => \App\Markdown\UserMentionResolver::class,
'embed' => \App\Markdown\OEmbedResolver::class,
],
];composer require birdcar/markdown-filamentDrop-in replacement for Filament's MarkdownEditor with server-side BFM preview:
use Birdcar\Markdown\Filament\Forms\Components\BfmEditor;
use Birdcar\Markdown\Filament\Tables\Columns\BfmTextColumn;
use Birdcar\Markdown\Filament\Infolists\Components\BfmTextEntry;
// Form field with live BFM preview
BfmEditor::make('content')
->previewDebounce(300);
// Table column that renders BFM
BfmTextColumn::make('content');
// Infolist entry that renders BFM
BfmTextEntry::make('content');The BfmEditor provides a preview toggle button that renders BFM syntax server-side via Filament v4's callSchemaComponentMethod — no traits or page-level configuration needed.
BfmEnvironmentFactory::create() returns a fully configured Environment with CommonMark, GFM (minus task lists, which BFM replaces), and all BFM extensions:
use Birdcar\Markdown\Environment\BfmEnvironmentFactory;
use Birdcar\Markdown\Environment\RenderProfile;
$environment = BfmEnvironmentFactory::create(
profile: RenderProfile::Html, // Html (default), Email, or Plain
embedResolver: $myEmbedResolver, // optional — implements EmbedResolverInterface
mentionResolver: $myMentionResolver, // optional — implements MentionResolverInterface
includeResolver: $myIncludeResolver, // optional — implements IncludeResolverInterface
queryResolver: $myQueryResolver, // optional — implements QueryResolverInterface
config: [], // additional league/commonmark config
);Each feature is a self-contained extension:
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\MarkdownConverter;
use Birdcar\Markdown\Inline\Task\TaskExtension;
use Birdcar\Markdown\Inline\TaskModifier\TaskModifierExtension;
$environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TaskExtension());
$environment->addExtension(new TaskModifierExtension());
$converter = new MarkdownConverter($environment);
echo $converter->convert('- [>] Call dentist //due:2025-03-01');| Class | Description |
|---|---|
FrontmatterExtension |
YAML front-matter (--- blocks) |
TaskExtension |
[x], [>], [!], etc. in list items |
TaskModifierExtension |
//due:2025-03-01, //hard metadata |
MentionExtension |
@username inline references |
HashtagExtension |
#project inline tags |
CalloutExtension |
@callout/@endcallout container blocks |
EmbedExtension |
@embed/@endembed leaf blocks |
DetailsExtension |
@details/@enddetails collapsible sections |
TabsExtension |
@tabs/@tab tabbed content groups |
FigureExtension |
@figure/@endfigure images with captions |
AsideExtension |
@aside/@endaside sidebar content |
MathExtension |
@math/@endmath LaTeX display blocks |
TocExtension |
@toc/@endtoc auto table of contents |
IncludeExtension |
@include/@endinclude file transclusion |
QueryExtension |
@query/@endquery dynamic content |
EndnotesExtension |
@endnotes/@endendnotes footnote rendering |
FootnoteExtension |
[^label] references and [^label]: definitions |
Mentions and embeds can be resolved at render time by implementing the contract interfaces.
MentionResolverInterface:
use Birdcar\Markdown\Contracts\MentionResolverInterface;
class UserMentionResolver implements MentionResolverInterface
{
public function resolve(string $identifier): ?array
{
$user = User::where('username', $identifier)->first();
return $user ? [
'label' => $user->display_name,
'url' => route('profile.show', $user),
] : null;
}
}Without a resolver, mentions render as <span class="mention">@identifier</span>. With a resolver that returns a URL, they render as <a href="..." class="mention">@label</a>.
EmbedResolverInterface:
use Birdcar\Markdown\Contracts\EmbedResolverInterface;
class OEmbedResolver implements EmbedResolverInterface
{
public function resolve(string $url): ?array
{
$response = Http::get('https://noembed.com/embed', ['url' => $url]);
return $response->successful() ? $response->json() : null;
}
}Without a resolver, embeds render as a <figure> with a plain link. With a resolver that returns html, the resolved HTML is embedded directly.
IncludeResolverInterface:
Resolve file transclusion for @include directives:
use Birdcar\Markdown\Contracts\IncludeResolverInterface;
class FileIncludeResolver implements IncludeResolverInterface
{
public function resolve(string $src, ?string $type, ?string $lines): ?string
{
$path = base_path($src);
return file_exists($path) ? file_get_contents($path) : null;
}
}Without a resolver, @include renders as a placeholder <div> with data attributes.
QueryResolverInterface:
Resolve dynamic content for @query directives:
use Birdcar\Markdown\Contracts\QueryResolverInterface;
use League\CommonMark\Node\Block\Document;
class TaskQueryResolver implements QueryResolverInterface
{
public function resolve(array $params, Document $document): array
{
return Task::query()
->when($params['state'] ?? null, fn ($q, $state) => $q->where('state', $state))
->when($params['tag'] ?? null, fn ($q, $tag) => $q->withTag($tag))
->limit($params['limit'] ?? 10)
->get()
->toArray();
}
}Without a resolver, @query renders as a placeholder <div> with data attributes.
ComputedFieldResolverInterface:
Extend metadata extraction with custom computed fields:
use Birdcar\Markdown\Contracts\ComputedFieldResolverInterface;
use League\CommonMark\Node\Block\Document;
class ReadabilityResolver implements ComputedFieldResolverInterface
{
public function resolve(Document $document, array $frontmatter, array $builtins): array
{
return [
'isLongRead' => $builtins['wordCount'] > 1000,
];
}
}Extract structured metadata from parsed documents:
use Birdcar\Markdown\Environment\BfmEnvironmentFactory;
use Birdcar\Markdown\Metadata\MetadataExtractor;
use League\CommonMark\MarkdownConverter;
use League\CommonMark\Parser\MarkdownParser;
$environment = BfmEnvironmentFactory::create();
$parser = new MarkdownParser($environment);
$document = $parser->parse(<<<'MD'
---
title: My Post
tags:
- bfm
---
A post about #typescript with a [link](https://example.com).
- [x] Write draft
- [ ] Publish //due:2025-06-01
MD);
$extractor = new MetadataExtractor();
$meta = $extractor->extract($document);
$meta->frontmatter; // ['title' => 'My Post', 'tags' => ['bfm']]
$meta->computed['wordCount']; // 9
$meta->computed['readingTime']; // 1
$meta->computed['tags']; // ['bfm', 'typescript']
$meta->computed['tasks']; // TaskCollection with ->open, ->done, ->all, etc.
$meta->computed['links']; // [LinkReference { url, title, line }]Custom computed fields via resolvers:
$extractor = new MetadataExtractor(
resolvers: [new ReadabilityResolver()],
);
$meta = $extractor->extract($document);
$meta->custom['isLongRead']; // falseDeep-merge front-matter and concatenate body content across multiple documents:
use Birdcar\Markdown\Merge\BfmDocument;
use Birdcar\Markdown\Merge\DocumentMerger;
use Birdcar\Markdown\Merge\MergeOptions;
use Birdcar\Markdown\Merge\MergeStrategy;
$a = new BfmDocument(frontmatter: ['tags' => ['a']], body: 'Content A');
$b = new BfmDocument(frontmatter: ['tags' => ['b'], 'title' => 'B'], body: 'Content B');
$merger = new DocumentMerger();
$merged = $merger->merge([$a, $b]);
// $merged->frontmatter = ['tags' => ['a', 'b'], 'title' => 'B']
// $merged->body = "Content A\n\nContent B"
// Configurable strategies
$merger->merge([$a, $b], new MergeOptions(strategy: MergeStrategy::FirstWins));
$merger->merge([$a, $b], new MergeOptions(strategy: MergeStrategy::Error)); // throws on scalar conflicts
$merger->merge([$a, $b], new MergeOptions(
strategy: fn (string $key, mixed $existing, mixed $incoming) => $existing . $incoming,
));
$merger->merge([$a, $b], new MergeOptions(separator: "\n---\n")); // custom body separatorThe @bfmStyles Blade directive (from birdcar/markdown-laravel) outputs a default stylesheet covering all BFM output elements. The stylesheet uses CSS custom properties for theming and supports both prefers-color-scheme: dark and class-based dark mode (.dark).
To publish the stylesheet for customization:
php artisan vendor:publish --tag=bfm-assetsOverride any variable in your own CSS:
:root {
--bfm-task-priority: #b91c1c;
--bfm-mention-bg: #fce7f3;
--bfm-mention-text: #9d174d;
}The Filament package automatically loads BFM styles into the admin panel.
---
title: My Document
tags:
- bfm
- markdown
author:
name: Nick
email: nick@birdcar.dev
---
Document content starts here.Front-matter must appear at the very start of the document. The YAML content is parsed and available via FrontmatterBlock::getParsedData().
Seven states, inspired by Bullet Journal:
- [ ] Open task
- [x] Completed
- [>] Scheduled for later
- [<] Migrated elsewhere
- [-] No longer relevant
- [o] Calendar event
- [!] High priorityInline metadata on task items using //key:value syntax:
- [>] Call dentist //due:2025-03-01
- [ ] Weekly review //every:weekly
- [o] Team retro //due:2025-02-07 //every:2-weeks
- [ ] Run backups //cron:0 9 * * 1
- [!] File taxes //due:2025-04-15 //hard
- [>] Wait for response //waitHey @sarah, can you review this? Also cc @john.doe and @dev-team.Discussing #typescript and #react-hooks in this post.Identifiers follow the pattern [a-zA-Z][a-zA-Z0-9_-]*. The # must not be preceded by an alphanumeric character. Hashtags inside code spans are not parsed.
Callouts (container — body is parsed as markdown):
@callout type=warning title="Watch Out"
This is a warning with **bold** text and [links](https://example.com).
@endcalloutEmbeds (leaf — body is treated as caption text):
@embed https://www.youtube.com/watch?v=dQw4w9WgXcQ
A classic internet moment.
@endembedDetails (container — collapsible section):
@details summary="Click to expand" open
Hidden content with **markdown** support.
@enddetailsTabs (container — tabbed content groups):
@tabs
@tab label="JavaScript" active
console.log('hello')
@endtab
@tab label="Python"
print('hello')
@endtab
@endtabsFigure (container — image with caption):
@figure src="photo.jpg" alt="A photo" id="fig-1"
Caption text with **markdown**.
@endfigureAside (container — sidebar content):
@aside title="Fun Fact"
Something tangential but interesting.
@endasideTOC (leaf — auto-generated table of contents):
@toc depth=2 ordered
@endtocMath (leaf — LaTeX display block):
@math label="eq-1"
E = mc^2
@endmathInclude, Query, Endnotes (leaf — resolver-dependent or structural):
@include src="./snippets/example.md" type=markdown
@endinclude
@query state=open tag=engineering limit=5
@endquery
@endnotes title="References"
@endendnotesPandoc-style footnote references and definitions:
Some text with a footnote[^1] and another[^note].
[^1]: First footnote content.
[^note]: Named footnote with longer content
that continues on indented lines.Footnotes are auto-numbered in order of first reference. If no @endnotes directive is present, the endnotes section is appended at the end of the document.
The extensions produce semantic, BEM-style HTML:
<!-- Task list item -->
<li data-task="scheduled" class="task-item task-item--scheduled">
<p>
<span class="task-marker task-marker--scheduled" title="Scheduled" data-state="scheduled">
<span class="task-marker__icon">▷</span>
</span>
Call dentist
<span class="task-mod task-mod--due" data-key="due" data-value="2025-03-01">//due:2025-03-01</span>
</p>
</li>
<!-- Mention -->
<a href="/users/sarah" class="mention">@Sarah Chen</a>
<!-- Callout -->
<aside class="callout callout--warning">
<div class="callout__header">Watch Out</div>
<div class="callout__body"><p>Content with <strong>markdown</strong>.</p></div>
</aside>
<!-- Embed (fallback) -->
<figure class="embed">
<a href="https://example.com/video" class="embed__link">https://example.com/video</a>
<figcaption class="embed__caption">A great video.</figcaption>
</figure>MIT