birdcar / markdown-php
Birdcar Flavored Markdown (BFM) — league/commonmark extension suite
Requires
- php: ^8.2
- league/commonmark: ^2.7
- symfony/yaml: ^7.2 || ^8.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-05-29 23:27:12 UTC
README
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.
Packages
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 |
Requirements
- PHP 8.2+
- league/commonmark ^2.7
- symfony/yaml ^8.0
Quick Start
Core library (any PHP project)
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);
Laravel
composer require birdcar/markdown-laravel
Zero 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, ], ];
Filament v4
composer require birdcar/markdown-filament
Drop-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.
The Factory
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 );
Individual extensions
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 |
Resolvers
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, ]; } }
Metadata Extraction
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']; // false
Document Merging
Deep-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 separator
Styling
The @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-assets
Override 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.
Syntax Reference
YAML Front-matter
--- 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().
Extended Task Lists
Seven states, inspired by Bullet Journal:
- [ ] Open task - [x] Completed - [>] Scheduled for later - [<] Migrated elsewhere - [-] No longer relevant - [o] Calendar event - [!] High priority
Task Modifiers
Inline 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 //wait
Mentions
Hey @sarah, can you review this? Also cc @john.doe and @dev-team.
Hashtags
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.
Directive Blocks
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). @endcallout
Embeds (leaf — body is treated as caption text):
@embed https://www.youtube.com/watch?v=dQw4w9WgXcQ A classic internet moment. @endembed
Details (container — collapsible section):
@details summary="Click to expand" open Hidden content with **markdown** support. @enddetails
Tabs (container — tabbed content groups):
@tabs @tab label="JavaScript" active console.log('hello') @endtab @tab label="Python" print('hello') @endtab @endtabs
Figure (container — image with caption):
@figure src="photo.jpg" alt="A photo" id="fig-1" Caption text with **markdown**. @endfigure
Aside (container — sidebar content):
@aside title="Fun Fact" Something tangential but interesting. @endaside
TOC (leaf — auto-generated table of contents):
@toc depth=2 ordered @endtoc
Math (leaf — LaTeX display block):
@math label="eq-1" E = mc^2 @endmath
Include, 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" @endendnotes
Footnotes
Pandoc-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.
HTML Output
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>
License
MIT