misskey_mfm_parser 2.0.0
misskey_mfm_parser: ^2.0.0 copied to clipboard
A library for parsing MFM rewritten in Dart. Developed with the aim of conducting tests as expected by mfm.js and obtaining equivalent parsing results.
misskey_mfm_parser Examples #
This document demonstrates how to use the misskey_mfm_parser package.
Table of Contents #
Full Parser #
The full parser supports all MFM syntax including inline styles, mentions, hashtags, URLs, MFM functions, and block elements.
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final parser = MfmParser().build();
const input = '''
Hello **bold** and *italic* text!
@user@example.com mentioned you
Check out #misskey
Visit https://misskey.io
:custom_emoji: and 😇
\$[shake 🍮]
> This is a quote
''';
final result = parser.parse(input);
if (result.isSuccess) {
final nodes = result.value; // List<MfmNode>
print('Parsed ${nodes.length} nodes');
for (final node in nodes) {
print(' - ${node.runtimeType}');
}
} else {
print('Parse error: ${result.message}');
}
}
Output:
Parsed 15 nodes
- TextNode
- BoldNode
- TextNode
- ItalicNode
- TextNode
- MentionNode
- TextNode
- HashtagNode
- TextNode
- UrlNode
- TextNode
- EmojiCodeNode
- TextNode
- UnicodeEmojiNode
- TextNode
- FnNode
- TextNode
- QuoteNode
Simple Parser #
The simple parser is a lightweight parser that only recognizes:
- Plain text
- Unicode emoji (e.g.,
😇,🎉) - Custom emoji codes (e.g.,
:wave:,:misskey:) - Plain tags (
<plain>...</plain>)
All other MFM syntax (bold, italic, mentions, hashtags, URLs, etc.) is treated as plain text.
Use cases:
- Displaying user names
- Notification previews
- Performance-critical scenarios where full parsing is not needed
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final simpleParser = MfmParser().buildSimple();
const input = 'Hello 😇 :wave: **not bold** @user #tag';
final result = simpleParser.parse(input);
if (result.isSuccess) {
for (final node in result.value) {
switch (node) {
case TextNode(:final text):
print('TextNode: "$text"');
case UnicodeEmojiNode(:final emoji):
print('UnicodeEmojiNode: $emoji');
case EmojiCodeNode(:final name):
print('EmojiCodeNode: :$name:');
default:
print('${node.runtimeType}');
}
}
}
}
Output:
TextNode: "Hello "
UnicodeEmojiNode: 😇
TextNode: " "
EmojiCodeNode: :wave:
TextNode: " **not bold** @user #tag"
Notice that **not bold**, @user, and #tag are treated as plain text.
Nest Limit Customization #
MFM allows nested structures (e.g., bold inside italic inside a link).
To prevent excessive nesting and potential performance issues, you can configure the nest limit. (We recommend using the default value to avoid differences from Misskey unless there is a specific reason.)
- Default: 20 (same as mfm.js)
- When the nest depth reaches the limit, nested syntax will be processed as plain text
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
// Create parser with custom nest limit
final parser = MfmParser().build(nestLimit: 5);
const input = '**bold *italic ~~strike **deeper** strike~~ italic* bold**';
final result = parser.parse(input);
if (result.isSuccess) {
printNodes(result.value);
}
}
void printNodes(List<MfmNode> nodes, {int indent = 0}) {
final prefix = ' ' * indent;
for (final node in nodes) {
switch (node) {
case TextNode(:final text):
print('$prefix TextNode: "$text"');
case BoldNode(:final children):
print('${prefix}BoldNode:');
printNodes(children, indent: indent + 1);
case ItalicNode(:final children):
print('${prefix}ItalicNode:');
printNodes(children, indent: indent + 1);
case StrikeNode(:final children):
print('${prefix}StrikeNode:');
printNodes(children, indent: indent + 1);
default:
print('$prefix${node.runtimeType}');
}
}
}
Node Type Handling #
When rendering or processing parsed MFM, you need to handle each node type appropriately. Here's an example:
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final parser = MfmParser().build();
const input = '''
**Welcome** to @alice@example.com's post!
Check #flutter and visit https://flutter.dev
Here's some code: `print("Hello")`
\$[spin 🌟]
''';
final result = parser.parse(input);
if (result.isSuccess) {
for (final node in result.value) {
processNode(node);
}
}
}
void processNode(MfmNode node, {int indent = 0}) {
final prefix = ' ' * indent;
switch (node) {
// Leaf nodes (no children)
case TextNode(:final text):
print('${prefix}Text: "$text"');
case UnicodeEmojiNode(:final emoji):
print('${prefix}UnicodeEmoji: $emoji');
case EmojiCodeNode(:final name):
print('${prefix}CustomEmoji: :$name:');
case MentionNode(:final username, :final host, :final acct):
print('${prefix}Mention: @$acct (user=$username, host=$host)');
case HashtagNode(:final hashtag):
print('${prefix}Hashtag: #$hashtag');
case UrlNode(:final url, :final brackets):
print('${prefix}URL: $url (brackets=$brackets)');
case InlineCodeNode(:final code):
print('${prefix}InlineCode: `$code`');
case CodeBlockNode(:final code, :final language):
print('${prefix}CodeBlock (lang=${language ?? "none"}):');
print('$prefix $code');
case MathInlineNode(:final formula):
print('${prefix}MathInline: \\($formula\\)');
case MathBlockNode(:final formula):
print('${prefix}MathBlock: \\[$formula\\]');
case SearchNode(:final query, :final content):
print('${prefix}Search: query="$query", content="$content"');
// Container nodes (with children)
case BoldNode(:final children):
print('${prefix}Bold:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case ItalicNode(:final children):
print('${prefix}Italic:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case StrikeNode(:final children):
print('${prefix}Strike:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case SmallNode(:final children):
print('${prefix}Small:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case CenterNode(:final children):
print('${prefix}Center:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case QuoteNode(:final children):
print('${prefix}Quote:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case PlainNode(:final children):
print('${prefix}Plain:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case LinkNode(:final url, :final silent, :final children):
print('${prefix}Link (url=$url, silent=$silent):');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case FnNode(:final name, :final args, :final children):
print('${prefix}Function \$[$name${_formatArgs(args)}]:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
}
}
String _formatArgs(Map<String, dynamic> args) {
if (args.isEmpty) return '';
final parts = args.entries.map((e) {
if (e.value == true) return e.key;
return '${e.key}=${e.value}';
});
return '.${parts.join(",")}';
}
Output:
Bold:
Text: "Welcome"
Text: " to "
Mention: @alice@example.com (user=alice, host=example.com)
Text: "'s post!
Check "
Hashtag: #flutter
Text: " and visit "
URL: https://flutter.dev (brackets=false)
Text: "
Here's some code: "
InlineCode: `print("Hello")`
Text: "
"
Function $[spin]:
UnicodeEmoji: 🌟
Text: "
"
Working with AST Nodes #
Value Equality #
All AST nodes support value-based equality comparison, making it easy to test and compare nodes:
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final node1 = TextNode('Hello');
final node2 = TextNode('Hello');
final node3 = TextNode('World');
print(node1 == node2); // true (same content)
print(node1 == node3); // false (different content)
// Works with complex nodes too
final bold1 = BoldNode([TextNode('test')]);
final bold2 = BoldNode([TextNode('test')]);
print(bold1 == bold2); // true
}
Immutable Updates with copyWith() #
You can create modified copies of nodes using the copyWith() method:
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final mention = MentionNode(
username: 'alice',
host: 'example.com',
acct: 'alice@example.com',
);
// Create a modified copy
final localMention = mention.copyWith(
host: null,
acct: 'alice',
);
print(mention.host); // example.com
print(localMention.host); // null
print(localMention.acct); // alice
// Works with lists too
final link = LinkNode(
url: 'https://example.com',
silent: false,
children: [TextNode('Click here')],
);
final silentLink = link.copyWith(silent: true);
print(silentLink.silent); // true
print(silentLink.url); // https://example.com (unchanged)
}
Debug Output with toString() #
All nodes provide readable toString() output for debugging:
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final nodes = [
TextNode('Hello'),
BoldNode([TextNode('world')]),
EmojiCodeNode('wave'),
];
for (final node in nodes) {
print(node);
}
// Output:
// TextNode(text: Hello)
// BoldNode(children: [TextNode(text: world)])
// EmojiCodeNode(name: wave)
}
Node Types Reference #
| Node Type | Has Children | Description |
|---|---|---|
TextNode |
No | Plain text |
UnicodeEmojiNode |
No | Unicode emoji (e.g., 😇) |
EmojiCodeNode |
No | Custom emoji (e.g., 👋) |
MentionNode |
No | User mention (@user or @user@host) |
HashtagNode |
No | Hashtag (#tag) |
UrlNode |
No | URL (https://...) |
InlineCodeNode |
No | Inline code (`code`) |
CodeBlockNode |
No | Code block (```code```) |
MathInlineNode |
No | Inline math (\(formula\)) |
MathBlockNode |
No | Block math (\[formula\]) |
SearchNode |
No | Search block |
BoldNode |
Yes | Bold text (**text**) |
ItalicNode |
Yes | Italic text (*text*) |
StrikeNode |
Yes | Strikethrough (~~text~~) |
SmallNode |
Yes | Small text (<small>text</small>) |
CenterNode |
Yes | Centered text (<center>text</center>) |
QuoteNode |
Yes | Quote block (> text) |
PlainNode |
Yes | Plain block (<plain>text</plain>) |
LinkNode |
Yes | Link ([label](url)) |
FnNode |
Yes | MFM function ($[name content]) |
日本語 #
このドキュメントでは misskey_mfm_parser パッケージの使い方を説明します。
目次 #
フルパーサー #
フルパーサーは、インラインスタイル、メンション、ハッシュタグ、URL、MFM関数、ブロック要素など、すべてのMFM構文をサポートします。
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final parser = MfmParser().build();
const input = '''
こんにちは **太字** と *斜体* のテキスト!
@user@example.com がメンションしました
#misskey をチェック
https://misskey.io にアクセス
:custom_emoji: と 😇
\$[shake 🍮]
> これは引用です
''';
final result = parser.parse(input);
if (result.isSuccess) {
final nodes = result.value; // List<MfmNode>
print('${nodes.length} 個のノードをパースしました');
for (final node in nodes) {
print(' - ${node.runtimeType}');
}
} else {
print('パースエラー: ${result.message}');
}
}
出力:
15 個のノードをパースしました
- TextNode
- BoldNode
- TextNode
- ItalicNode
- TextNode
- MentionNode
- TextNode
- HashtagNode
- TextNode
- UrlNode
- TextNode
- EmojiCodeNode
- TextNode
- UnicodeEmojiNode
- TextNode
- FnNode
- TextNode
- QuoteNode
シンプルパーサー #
シンプルパーサーは、以下のみを認識する軽量パーサーです:
- プレーンテキスト
- Unicode絵文字(例:
😇、🎉) - カスタム絵文字コード(例:
:wave:、:misskey:) - plainタグ(
<plain>...</plain>)
その他のMFM構文(太字、斜体、メンション、ハッシュタグ、URLなど)はすべてプレーンテキストとして扱われます。
ユースケース:
- ユーザー名の表示
- 通知のプレビュー
- フルパースが不要なパフォーマンス重視の場面
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final simpleParser = MfmParser().buildSimple();
const input = 'こんにちは 😇 :wave: **太字ではない** @user #tag';
final result = simpleParser.parse(input);
if (result.isSuccess) {
for (final node in result.value) {
switch (node) {
case TextNode(:final text):
print('TextNode: "$text"');
case UnicodeEmojiNode(:final emoji):
print('UnicodeEmojiNode: $emoji');
case EmojiCodeNode(:final name):
print('EmojiCodeNode: :$name:');
default:
print('${node.runtimeType}');
}
}
}
}
出力:
TextNode: "こんにちは "
UnicodeEmojiNode: 😇
TextNode: " "
EmojiCodeNode: :wave:
TextNode: " **太字ではない** @user #tag"
**太字ではない**、@user、#tag がプレーンテキストとして扱われます。
ネスト制限のカスタマイズ #
MFMではネスト構造(例:リンク内の斜体内の太字)が可能です。
過度なネストによるパフォーマンス問題を防ぐため、ネスト制限を設定できます。(特別理由がなければmisskeyとの差異を防ぐ為にデフォルト値を使う事を推奨)
- デフォルト値: 20(mfm.jsと同じ)
- ネスト深度が制限に達すると、それ以降のネスト構文はプレーンテキストとして処理されます
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
// カスタムネスト制限でパーサーを作成
final parser = MfmParser().build(nestLimit: 5);
const input = '**太字 *斜体 ~~取り消し **さらに深く** 取り消し~~ 斜体* 太字**';
final result = parser.parse(input);
if (result.isSuccess) {
printNodes(result.value);
}
}
void printNodes(List<MfmNode> nodes, {int indent = 0}) {
final prefix = ' ' * indent;
for (final node in nodes) {
switch (node) {
case TextNode(:final text):
print('$prefix TextNode: "$text"');
case BoldNode(:final children):
print('${prefix}BoldNode:');
printNodes(children, indent: indent + 1);
case ItalicNode(:final children):
print('${prefix}ItalicNode:');
printNodes(children, indent: indent + 1);
case StrikeNode(:final children):
print('${prefix}StrikeNode:');
printNodes(children, indent: indent + 1);
default:
print('$prefix${node.runtimeType}');
}
}
}
ノードタイプ別の処理 #
パースされたMFMをレンダリングまたは処理する際は、各ノードタイプを適切に処理する必要があります。以下は行う際の例です:
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final parser = MfmParser().build();
const input = '''
**ようこそ** @alice@example.com さんの投稿へ!
#flutter をチェックして https://flutter.dev にアクセス
コード例: `print("Hello")`
\$[spin 🌟]
''';
final result = parser.parse(input);
if (result.isSuccess) {
for (final node in result.value) {
processNode(node);
}
}
}
void processNode(MfmNode node, {int indent = 0}) {
final prefix = ' ' * indent;
switch (node) {
// リーフノード(子要素なし)
case TextNode(:final text):
print('${prefix}Text: "$text"');
case UnicodeEmojiNode(:final emoji):
print('${prefix}UnicodeEmoji: $emoji');
case EmojiCodeNode(:final name):
print('${prefix}CustomEmoji: :$name:');
case MentionNode(:final username, :final host, :final acct):
print('${prefix}Mention: @$acct (user=$username, host=$host)');
case HashtagNode(:final hashtag):
print('${prefix}Hashtag: #$hashtag');
case UrlNode(:final url, :final brackets):
print('${prefix}URL: $url (brackets=$brackets)');
case InlineCodeNode(:final code):
print('${prefix}InlineCode: `$code`');
case CodeBlockNode(:final code, :final language):
print('${prefix}CodeBlock (lang=${language ?? "none"}):');
print('$prefix $code');
case MathInlineNode(:final formula):
print('${prefix}MathInline: \\($formula\\)');
case MathBlockNode(:final formula):
print('${prefix}MathBlock: \\[$formula\\]');
case SearchNode(:final query, :final content):
print('${prefix}Search: query="$query", content="$content"');
// コンテナノード(子要素あり)
case BoldNode(:final children):
print('${prefix}Bold:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case ItalicNode(:final children):
print('${prefix}Italic:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case StrikeNode(:final children):
print('${prefix}Strike:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case SmallNode(:final children):
print('${prefix}Small:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case CenterNode(:final children):
print('${prefix}Center:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case QuoteNode(:final children):
print('${prefix}Quote:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case PlainNode(:final children):
print('${prefix}Plain:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case LinkNode(:final url, :final silent, :final children):
print('${prefix}Link (url=$url, silent=$silent):');
for (final child in children) {
processNode(child, indent: indent + 1);
}
case FnNode(:final name, :final args, :final children):
print('${prefix}Function \$[$name${_formatArgs(args)}]:');
for (final child in children) {
processNode(child, indent: indent + 1);
}
}
}
String _formatArgs(Map<String, dynamic> args) {
if (args.isEmpty) return '';
final parts = args.entries.map((e) {
if (e.value == true) return e.key;
return '${e.key}=${e.value}';
});
return '.${parts.join(",")}';
}
ASTノードの操作 #
値の等価性 #
ASTノードは値ベースの等価性比較をサポート。ノードのテスト、比較が可能:
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final node1 = TextNode('こんにちは');
final node2 = TextNode('こんにちは');
final node3 = TextNode('さようなら');
print(node1 == node2); // true(内容が同じ)
print(node1 == node3); // false(内容が異なる)
// 複雑なノードでも動作します
final bold1 = BoldNode([TextNode('テスト')]);
final bold2 = BoldNode([TextNode('テスト')]);
print(bold1 == bold2); // true
}
copyWith() によるイミュータブルな更新 #
copyWith()メソッドを使用してノードの変更されたコピーを作成可能:
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final mention = MentionNode(
username: 'alice',
host: 'example.com',
acct: 'alice@example.com',
);
// 変更されたコピーを作成
final localMention = mention.copyWith(
host: null,
acct: 'alice',
);
print(mention.host); // example.com
print(localMention.host); // null
print(localMention.acct); // alice
// リストを持つノードでも動作します
final link = LinkNode(
url: 'https://example.com',
silent: false,
children: [TextNode('ここをクリック')],
);
final silentLink = link.copyWith(silent: true);
print(silentLink.silent); // true
print(silentLink.url); // https://example.com(変更なし)
}
toString() によるデバッグ出力 #
ノードはtoString()出力を提供:
import 'package:misskey_mfm_parser/misskey_mfm_parser.dart';
void main() {
final nodes = [
TextNode('こんにちは'),
BoldNode([TextNode('世界')]),
EmojiCodeNode('wave'),
];
for (final node in nodes) {
print(node);
}
// 出力:
// TextNode(text: こんにちは)
// BoldNode(children: [TextNode(text: 世界)])
// EmojiCodeNode(name: wave)
}
ノードタイプ一覧 #
| ノードタイプ | 子要素あり | 説明 |
|---|---|---|
TextNode |
なし | プレーンテキスト |
UnicodeEmojiNode |
なし | Unicode絵文字(例:😇) |
EmojiCodeNode |
なし | カスタム絵文字(例:👋) |
MentionNode |
なし | ユーザーメンション(@user または @user@host) |
HashtagNode |
なし | ハッシュタグ(#tag) |
UrlNode |
なし | URL(https://...) |
InlineCodeNode |
なし | インラインコード(`code`) |
CodeBlockNode |
なし | コードブロック(```code```) |
MathInlineNode |
なし | インライン数式(\(formula\)) |
MathBlockNode |
なし | ブロック数式(\[formula\]) |
SearchNode |
なし | 検索ブロック |
BoldNode |
あり | 太字(**text**) |
ItalicNode |
あり | 斜体(*text*) |
StrikeNode |
あり | 取り消し線(~~text~~) |
SmallNode |
あり | 小文字(<small>text</small>) |
CenterNode |
あり | 中央寄せ(<center>text</center>) |
QuoteNode |
あり | 引用ブロック(> text) |
PlainNode |
あり | プレーンブロック(<plain>text</plain>) |
LinkNode |
あり | リンク([label](url)) |
FnNode |
あり | MFM関数($[name content]) |