@birdcar/markdown
v1.1.1
Published
Birdcar Flavored Markdown (BFM) parser and renderer — unified/remark plugins
Maintainers
Readme
@birdcar/markdown
unified / remark plugin 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.
Install
npm install @birdcar/markdown remark-parse remark-gfm unified
# or
bun add @birdcar/markdown remark-parse remark-gfm unifiedFor HTML output, also install:
npm install remark-rehype rehype-stringifyUsage
Parse and render all BFM features
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import { createBfmProcessor } from '@birdcar/markdown'
const file = await createBfmProcessor()
.use(remarkRehype)
.use(rehypeStringify)
.process(`
---
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
`)
console.log(String(file))Parse or analyze BFM source
createBfmProcessor() is the supported all-feature composition. It includes GFM and gives BFM task markers and footnotes precedence, so [ ], [x], and all extended task states produce the same BFM node model.
import {
analyzeBfm,
createBfmProcessor,
parseBfm,
} from '@birdcar/markdown'
const processor = createBfmProcessor()
const parsed = processor.parse('- [>] Ship //due:2025-09-01')
const transformed = processor.runSync(parsed)
const tree = parseBfm('- [>] Ship //due:2025-09-01')
const analysis = analyzeBfm('- [>] Ship //due:2025-09-01')parseBfm(source) is strict: it always runs parser transforms and throws for source errors such as an undefined footnote. analyzeBfm(source) is resilient for editors and indexers. Source errors return tree: null, empty safe metadata and symbols, and a diagnostic; programming or directive-configuration errors still throw.
Analysis contains concrete symbols for tasks, mentions, hashtags, footnote references and definitions, and directives. Every source range uses JavaScript UTF-16 offsets, matching CodeMirror and source.slice(start.offset, end.offset). Task symbols include the list-item range, the single marker-character range, the raw text range, and each modifier range.
const source = '😀 - not a task\n- [!] Ship **today** //hard\n'
const { symbols, diagnostics } = analyzeBfm(source)
const task = symbols.tasks[0]
source.slice(task.markerRange.start.offset, task.markerRange.end.offset) // '!'
source.slice(task.modifiers[0].range.start.offset, task.modifiers[0].range.end.offset) // '//hard'
diagnostics // []Use individual plugins
Each feature is a standalone remark plugin. Use only what you need:
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import { remarkBfmTasks } from '@birdcar/markdown/tasks'
import { remarkBfmModifiers } from '@birdcar/markdown/modifiers'
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBfmTasks)
.use(remarkBfmModifiers)Available sub-plugins and utilities:
| Import path | Plugin / Export | Description |
|---|---|---|
| @birdcar/markdown | remarkBfm | All features combined |
| @birdcar/markdown/frontmatter | remarkBfmFrontmatter | YAML front-matter (--- blocks) |
| @birdcar/markdown/tasks | remarkBfmTasks | [x], [>], [!], etc. in list items |
| @birdcar/markdown/modifiers | remarkBfmModifiers | //due:2025-03-01, //hard |
| @birdcar/markdown/mentions | remarkBfmMentions | @username inline references |
| @birdcar/markdown/hashtags | remarkBfmHashtags | #project inline tags |
| @birdcar/markdown/directives | remarkBfmDirectives | Directive block parser with built-in and custom directives |
| @birdcar/markdown/footnotes | remarkBfmFootnotes | [^label] references and definitions |
| @birdcar/markdown/analysis | analyzeBfm | Resilient metadata, symbols, ranges, and diagnostics |
| @birdcar/markdown/metadata | extractMetadata | Computed fields from parsed documents |
| @birdcar/markdown/merge | mergeDocuments | Deep merge of front-matter + body |
Work with the AST directly
import { parseBfm } from '@birdcar/markdown'
import type { TaskMarkerNode, TaskModifierNode, MentionNode } from '@birdcar/markdown'
import { visit } from 'unist-util-visit'
const mdast = parseBfm('- [>] Call dentist //due:2025-03-01')
visit(mdast, 'taskModifier', (node: TaskModifierNode) => {
console.log(node.key, node.value) // "due", "2025-03-01"
})Serialize back to markdown
The plugins include toMarkdown extensions, so round-tripping works:
import remarkStringify from 'remark-stringify'
import { createBfmProcessor } from '@birdcar/markdown'
const processor = createBfmProcessor().use(remarkStringify)
const result = processor.processSync('- [>] Call dentist //due:2025-03-01')
console.log(String(result))
// - [>] Call dentist //due:2025-03-01Extract metadata
import { extractMetadata, parseBfm } from '@birdcar/markdown'
const tree = parseBfm(`
---
title: My Post
tags:
- bfm
---
A post about #typescript with a [link](https://example.com).
- [x] Write draft
- [ ] Publish //due:2025-06-01
`)
const meta = extractMetadata(tree)
meta.frontmatter // { title: 'My Post', tags: ['bfm'] }
meta.computed.wordCount // 9
meta.computed.readingTime // 1
meta.computed.tags // ['bfm', 'typescript']
meta.computed.tasks.done // [{ text: 'Write draft', state: 'done', ... }]
meta.computed.tasks.open // [{ text: 'Publish', state: 'open', modifiers: [{ key: 'due', value: '2025-06-01' }] }]
meta.computed.links // [{ url: 'https://example.com', title: null }]Custom computed fields via resolvers:
const meta = extractMetadata(tree, {
computedFields: [
(tree, frontmatter, builtins) => ({
isLongRead: builtins.wordCount > 1000,
}),
],
})
meta.custom.isLongRead // falseUse in a browser
The parser and analysis entry points have no Node built-in, filesystem, or Obsidian dependency and can be bundled directly for browsers:
import { analyzeBfm, parseBfm } from '@birdcar/markdown'
import { analyzeBfm as analyzeFromSubpath } from '@birdcar/markdown/analysis'
const tree = parseBfm('- [ ] Browser task')
const analysis = analyzeFromSubpath('#browser')The release checks bundle a consumer with esbuild using platform: 'browser' and import every public feature subpath.
Merge documents
import { mergeDocuments } from '@birdcar/markdown'
import type { BfmDocument } from '@birdcar/markdown'
const a: BfmDocument = { frontmatter: { tags: ['a'] }, body: 'Content A' }
const b: BfmDocument = { frontmatter: { tags: ['b'], title: 'B' }, body: 'Content B' }
const merged = mergeDocuments([a, b])
// merged.frontmatter = { tags: ['a', 'b'], title: 'B' }
// merged.body = 'Content A\n\nContent B'
// Configurable strategies
mergeDocuments([a, b], { strategy: 'first-wins' })
mergeDocuments([a, b], { strategy: 'error' }) // throws on scalar conflicts
mergeDocuments([a, b], { strategy: (key, existing, incoming) => existing + incoming })
mergeDocuments([a, b], { separator: '\n---\n' }) // custom body separatorSyntax Reference
YAML Front-matter
---
title: My Document
tags:
- bfm
- markdown
author:
name: Nick
email: [email protected]
---
Document content starts here.Front-matter must appear at the very start of the document. The YAML content is parsed and available on the AST node's data property.
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 priorityTask 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 //waitMentions
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).
@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 (leaf — file transclusion, resolver-dependent):
@include src="./snippets/example.md" type=markdown
@endincludeQuery (leaf — dynamic content, resolver-dependent):
@query state=open tag=engineering limit=5
@endqueryEndnotes (leaf — footnote rendering location):
@endnotes title="References"
@endendnotesCustom Directives
remarkBfm (and remarkBfmDirectives) accept an optional directives map that registers additional directive types — or overrides built-ins.
import { remarkBfm } from '@birdcar/markdown'
import type { DirectiveDefinition } from '@birdcar/markdown'
const deck: DirectiveDefinition = { kind: 'container' }
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBfm, {
directives: {
deck: { kind: 'container' },
slide: { kind: 'container' },
},
})DirectiveDefinition
interface DirectiveDefinition {
kind: 'container' | 'leaf'
toHast?: HastData | ((node: DirectiveBlockNode) => HastData)
transform?: (node: DirectiveBlockNode, ctx: DirectiveContext) => void
}kind— required.'container'parses the body as full BFM markdown;'leaf'stores it as raw text innode.meta.body.toHast— shorthand to attach{ hName, hProperties, hChildren }onto the node soremark-rehyperenders it as a custom element. Accepts a static object or a function that receives the node.transform— escape hatch for complex transformations (mutatenodeorctx.treedirectly). When present,toHastis ignored.
toHast shorthand example
import { remarkBfm } from '@birdcar/markdown'
unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBfm, {
directives: {
badge: {
kind: 'leaf',
toHast: (node) => ({
hName: 'span',
hProperties: {
class: `badge badge--${String(node.params.type ?? 'default')}`,
},
}),
},
},
})transform escape-hatch example
import type { DirectiveContext } from '@birdcar/markdown'
import type { DirectiveBlockNode } from '@birdcar/markdown'
unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBfm, {
directives: {
warning: {
kind: 'container',
transform: (node: DirectiveBlockNode, _ctx: DirectiveContext) => {
node.data = {
hName: 'aside',
hProperties: { class: 'warning', role: 'note' },
}
},
},
},
})Deck recipe (@click / @steps)
@click and @steps are no longer built-in directives. Register them manually and render with your own transform or renderer:
import { remarkBfm } from '@birdcar/markdown'
unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBfm, {
directives: {
click: { kind: 'container' },
steps: { kind: 'container' },
},
})With no toHast or transform, the nodes parse correctly and land in the MDAST as directiveBlock nodes — leaving rendering entirely to your own rehype plugin or serializer.
Behavior notes
- Unregistered directive — still parses; treated as
kind: 'container'with no render data attached. The node is present in the MDAST. - Close fence required — a matching
@endnamefence is required. Without it the opening line falls back to a paragraph. - Override built-ins — a custom definition with the same name as a built-in takes precedence.
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.
Types
All AST node types, metadata types, and contracts are exported:
import type {
// AST nodes
TaskState, // 'open' | 'done' | 'scheduled' | 'migrated' | 'irrelevant' | 'event' | 'priority'
TaskMarkerChar, // ' ' | 'x' | '>' | '<' | '-' | 'o' | '!'
TaskMarkerNode, // { type: 'taskMarker', state: TaskState }
TaskModifierNode, // { type: 'taskModifier', key: string, value: string | null }
MentionNode, // { type: 'mention', identifier: string }
HashtagNode, // { type: 'hashtag', identifier: string }
YamlNode, // { type: 'yaml', data: Record<string, unknown> }
DirectiveBlockNode, // { type: 'directiveBlock', name: string, params: Record<string, string | boolean> }
FootnoteRefNode, // { type: 'footnoteRef', label: string }
FootnoteDefNode, // { type: 'footnoteDef', label: string }
// Metadata
DocumentMetadata, // { frontmatter, computed: BuiltinMetadata, custom }
BuiltinMetadata, // { wordCount, readingTime, tasks, tags, links }
TaskCollection, // { all, open, done, scheduled, ... }
ExtractedTask, // { text, state, modifiers, line, range, markerRange, textRange }
SourcePoint, // { line, column, offset } with a required UTF-16 offset
SourceRange, // { start: SourcePoint, end: SourcePoint }
BfmAnalysis, // { tree, metadata, symbols, diagnostics }
BfmDiagnostic, // { code, message, severity, range? }
AnalyzedTask, // source-safe task semantics and ranges
LinkReference, // { url, title, line }
// Merge
BfmDocument, // { frontmatter, body }
MergeOptions, // { strategy, separator }
MergeStrategy, // 'last-wins' | 'first-wins' | 'error'
MergeResolver, // (key, existing, incoming) => value
// Directive registration
RemarkBfmOptions, // { directives?: Record<string, DirectiveDefinition> }
DirectiveDefinition, // { kind, toHast?, transform? }
DirectiveContext, // { tree: Root }
// Contracts
EmbedResolver,
MentionResolver,
ComputedFieldResolver,
} from '@birdcar/markdown'License
MIT
