flutter_markdownでカスタムのブロックタグを実装する方法

はじめに

flutter_markdown | Flutter package

flutter_markdownとは、FlutterでMarkdown形式のテキストを表示するためのライブラリです。Markdown記法で記述されたテキストを簡単に変換・表示できるのが特徴です。
ただし、Markdown記法に含まれない表現を実現したい場合には、追加の方法が必要です。

Markdown記法には、ブロックタグとインラインタグの2種類があります。
本記事では、カスタムのブロックタグについて取り上げます。インラインタグについては、別の機会に詳しく説明します。

今回作るもの

<embed_link title="..." description="..." image_url="..." favicon_url="..." url="..." />

今回は上記の文字列を下の画像のような埋め込みリンクになるように作っていきます。

それぞれの要素名を下記として扱っていきます。

実装方法

MarkdownBodyの説明

FlutterでMarkdownを表示するためには、 MarkdownBody を使用します。このWidgetを利用することで、Markdown形式のテキストを簡単にレンダリングできます。

また、ブロックタグをカスタマイズして表示するためには、以下の2つのプロパティを設定する必要があります:

以下はその基本的なコード例です:

return fmd.MarkdownBody(
  // 表示するMarkdownの文字列
  data: content,
  blockSyntaxes: <BlockSyntax>[
    // ...
  ],
  builders: <String, MarkdownElementBuilder>{
    // ...
  },
);

blockSyntaxesの説明

blockSyntaxes は、以下のように定義されています:

/// Collection of custom block syntax types to be used parsing the Markdown data.
final List<md.BlockSyntax>? blockSyntaxes;

このプロパティは、Markdownデータをパースする際に使用するカスタムブロックタグのコレクションを指定するためのものです。
簡単に言えば、data に渡した文字列を解析し、特定のブロックタグとして解釈するために必要な設定を行う役割を担います。

buildersの説明

builders は、以下のように定義されています:

/// Render certain tags, usually used with [extensionSet]
///
/// For example, we will add support for `sub` tag:
///
/// ```dart
/// builders: {
///   'sub': SubscriptBuilder(),
/// }
/// ```
///
/// The `SubscriptBuilder` is a subclass of [MarkdownElementBuilder].
final Map<String, MarkdownElementBuilder> builders;

このプロパティは、特定のタグをレンダリングする際に必要な設定を行うためのものです。
例えば、sub タグをサポートするには、次のように SubscriptBuilder を指定します:

builders: {
  'sub': SubscriptBuilder(),
}

なお、SubscriptBuilderMarkdownElementBuilder を継承したクラスです。

builders は、blockSyntaxes で設定したタグをレンダリングする場合に使用されますが、blockSyntaxes を使用しないタグのカスタマイズにも活用できます。

blockSyntaxesの実装

以下は、 blockSyntaxes にカスタムのブロックタグを追加する例です。

import 'package:flutter_markdown/flutter_markdown.dart' as fmd;
import 'package:markdown/markdown.dart' as md;

class EmbedLinkSyntax extends md.BlockSyntax {
  const EmbedLinkSyntax();

  @override
  RegExp get pattern => RegExp(
        r'<embed_link\s+(?=.*title="([^"]+)")(?=.*description="([^"]+)")(?=.*image_url="([^"]+)")(?=.*favicon_url="([^"]+)")(?=.*url="([^"]+)").*?\/?>',
      );

  @override
  md.Node? parse(md.BlockParser parser) {
    final match = pattern.firstMatch(parser.current.content);
    if (match != null) {
      final title = match.group(1) ?? '';
      final description = match.group(2) ?? '';
      final imageUrl = match.group(3) ?? '';
      final faviconUrl = match.group(4) ?? '';
      final url = match.group(5) ?? '';

      // カスタムノードを作成
      final element = md.Element('embed_link', []);
      element.attributes.addAll({
        'title': title,
        'description': description,
        'image_url': imageUrl,
        'favicon_url': faviconUrl,
        'url': url,
      });

      // パーサーを次の行に進める
      parser.advance();

      return element;
    }

    return null;
  }
}

1. BlockSyntaxを継承したクラスの作成

カスタムのタグを追加するために、BlockSyntax を継承したクラスを作成します。

2. patternの定義

パース時にカスタムタグ embed_link を識別するための正規表現を定義します:

@override
RegExp get pattern => RegExp(
      r'<embed_link\s+(?=.*title="([^"]+)")(?=.*description="([^"]+)")(?=.*image_url="([^"]+)")(?=.*favicon_url="([^"]+)")(?=.*url="([^"]+)").*?\/?>',
    );

正規表現の説明

※ title、description、image_url、favicon_url、url は順不同で記述可能です。

3. parseメソッドの実装

pattern に一致するタグから値を取得し、カスタムノード(Element)を作成します:

@override
md.Node? parse(md.BlockParser parser) {
  final match = pattern.firstMatch(parser.current.content);
  if (match != null) {
    final title = match.group(1) ?? '';
    final description = match.group(2) ?? '';
    final imageUrl = match.group(3) ?? '';
    final faviconUrl = match.group(4) ?? '';
    final url = match.group(5) ?? '';

    // カスタムノードを作成
    final element = md.Element('embed_link', []);
    element.attributes.addAll({
      'title': title,
      'description': description,
      'image_url': imageUrl,
      'favicon_url': faviconUrl,
      'url': url,
    });

    // パーサーを次の行に進める
    parser.advance();

    return element;
  }

  return null;
}

これで、blockSyntaxes 側の実装は完了です。

MarkdownElementBuilderの実装

次に、 MarkdownBodybuilders に設定する MarkdownElementBuilder の実装について解説します。

class EmbedLinkBuilder extends fmd.MarkdownElementBuilder {
  @override
  bool isBlockElement() => true;

  @override
  Widget? visitElementAfterWithContext(
    BuildContext context,
    md.Element element,
    TextStyle? preferredStyle,
    TextStyle? parentStyle,
  ) {
    final title = element.attributes['title'];
    final description = element.attributes['description'];
    final imageUrl = element.attributes['image_url'];
    final faviconUrl = element.attributes['favicon_url'];
    final url = element.attributes['url'];

    // 表示するWidgetを作成
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 16),
      child: ...
    );
  }
}

1. MarkdownElementBuilderを継承したクラスを作成

カスタム要素をレンダリングするために、 MarkdownElementBuilder を継承したクラスを作成します。

2. isBlockElementのオーバーライド

ブロック要素であることを指定するために、 isBlockElement メソッドを true に設定します:

@override
bool isBlockElement() => true;

3. visitElementAfterWithContextの実装

カスタムタグがレンダリングされる際に表示するWidgetを定義します:

@override
Widget? visitElementAfterWithContext(
  BuildContext context,
  md.Element element,
  TextStyle? preferredStyle,
  TextStyle? parentStyle,
) {
  final title = element.attributes['title'];
  final description = element.attributes['description'];
  final imageUrl = element.attributes['image_url'];
  final faviconUrl = element.attributes['favicon_url'];
  final url = element.attributes['url'];

  // 表示するWidgetを作成
  return Padding(
    padding: const EdgeInsets.symmetric(vertical: 16),
    child: ...
  );
}

表示内容の作成手順

これで MarkdownElementBuilder の実装は完了です。

MarkdownBodyの実装

これまでに作成した EmbedLinkSyntaxEmbedLinkBuilderMarkdownBody にセットします。

return fmd.MarkdownBody(
  blockSyntaxes: const [
    EmbedLinkSyntax(),
  ],
  builders: {
    'embed_link': EmbedLinkBuilder(),
  },
);

設定内容

これでカスタムブロックタグとそのレンダリング処理を含む MarkdownBody の実装が完成です!

補足

補足1: embed_linkの内容について

今回、embed_link には title、description、image_url、favicon_url、url など、表示に必要な値を直接設定しています。

しかし、より適切な方法として、embed_link には url のみを設定し、EmbedLinkBuilder 側で url を基にスクレイピングを行い、表示に必要なデータを取得する方法があります。この方法のほうが柔軟性や拡張性に優れています。

今回その方法を採用しなかった理由

Flutter Web では CORS(Cross-Origin Resource Sharing) の問題があるため、スクレイピングによるデータ取得を実現するのは困難でした。
以下のリンク先には解決方法が記載されていますが、SDKの直接変更が必要だったため、今回は断念しました。

How to solve flutter web api cors error only with dart code? - Stack Overflow

補足2: embed_link に必要な値の取得について

補足1で述べた通り、 embed_link には表示に必要なすべての値を直接指定していますが、毎回手動で値を取得するのは手間です。そのため、コマンドラインツールを作成して効率化しました。このツールでは、 htmlhttp パッケージを利用して必要なデータを自動取得します。

コマンドツールコード

// ignore_for_file: avoid_print

import 'package:html/parser.dart' show parse;
import 'package:http/http.dart' as http;

/// dart scraping_site.dart <url>
Future<void> main(List<String> args) async {
  if (args.isEmpty) {
    print('Usage: dart scraper.dart <url>');
    return;
  }

  final url = args[0];
  try {
    final response = await http.get(Uri.parse(url));

    if (response.statusCode == 200) {
      final document = parse(response.body);

      // Title
      final title = document.querySelector('title')?.text ?? '';
      // Description
      final description = document.querySelector('meta[name="description"]')?.attributes['content'] ?? '';
      // Favicon
      final favicon = document.querySelector('link[rel~="icon"]')?.attributes['href'] ?? '';
      // OGP Image
      final ogImage = document.querySelector('meta[property="og:image"]')?.attributes['content'] ?? '';

      print(
        '<embed_link title="$title" description="$description" image_url="$ogImage" favicon_url="$favicon" url="$url" />',
      );
    } else {
      print('Failed: Status code: ${response.statusCode}');
    }
  } on Exception catch (e) {
    print('Error: $e');
  }
}

コードの説明

これにより、対象サイトの情報を簡単に取得し、embed_link タグを生成することが可能になります。

最後に

カスタムのブロックタグの実装方法について解説しました。これでMarkdownの表示に自由度が増したと思います。

別の機会にカスタムのインラインタグの実装方法について書きたいと思います。