WebMatrix 2: Markdown を汎用的に拡張する仕組みを考えてみる

執筆日時:

Markdown は覚えやすくて書きやすいのだけれど、とても非力に感じる。一応 HTML タグの埋め込みも可能なので、原理的にはなんでも書けるのだけれど、たとえばルビを振りたい場合、

国民の<ruby>税金<rp>(</rp><rt>ぜいきん</rt><rp>)</rp></ruby>を2億円使うなんて

などといちいち書くのは、読みにくいし第一めんどくさい。もっと簡単に、たとえば、

国民の[[ruby|税金|ぜいきん]]を2億円使うなんて

などのような、[[コマンド|引数1|引数2...]] といった記法で書ければどうだろう。なるべく規約ベースとし、Hoge コマンドは Hoge / HogeHelper ヘルパーの GetHtml() メソッドを呼び出すようにする。

# App_Code/RubyHelper.cshtml

@helper GetHtml(string text, string ruby){ <ruby>@text<rp>(</rp><rt>@ruby</rt><rp>)</rp></ruby> }

これならば、Markdown の拡張だけでなく、普通の cshtml でも利用できてよいと思う。

国民の@RubyHelper.GetHtml("税金", "ぜいきん")を2億円使うなんて

実装

とりあえずこんな感じにしてみた。

@using System.IO
@using System.Reflection
@using System.Text.RegularExpressions

@functions { private string Camelize(string input) { if (input.Length == 0) return input;

var chars = input.ToArray(); chars[0] = char.ToUpper(chars[0]); return string.Join(string.Empty, chars); } }

@{ // テストテキストをロード var text = File.ReadAllText(Server.MapPath("~/Test.txt"));

// HtmlHelper の子孫を列挙して型名-型ディクショナリを作成 var type_table = AppDomain.CurrentDomain .GetAssemblies() .SelectMany(_ => _.GetTypes()) .Where(_ => _.IsSubclassOf(typeof(HelperPage))) .ToDictionary(_ => _.ToString(), _ => _);

// [[…]] 構文を置換 var regex = new Regex(@"[[(?<params>[^[]\r\n]*)]]"); text = regex.Replace(text, (MatchEvaluator)((match) => { // [[…]]構文の書式 // - [[コマンド|引数1|引数2|…]] // - [[引数1|引数2|引数3…]] : Link コマンドと解釈(規定) var p = match.Groups["params"].Value.Split('|');

// コマンド名は Hoge, HogeHelper … を許容 var helper_table = new string[] { string.Format("ASP.{0}", Camelize(p[0])), string.Format("ASP.{0}Helper", Camelize(p[0])), };

Type helper = null; MethodInfo method = null; string[] args = null;

// 型名-型ディクショナリから、メソッド // (Type: p[0]).GetHtml(p[1], p[2]…) // をもつ HtmlHelper を探す var result = helper_table.FirstOrDefault(name => { if (type_table.TryGetValue(name, out helper)) { args = p.Skip(1).ToArray(); method = helper.GetMethod( "GetHtml", args.Select(_ => _.GetType()).ToArray() ); } return method != null; });

// 見つからなかった場合は、既定の型・メソッドを利用する if (string.IsNullOrEmpty(result)) { helper = typeof(LinkHelper); args = p; method = helper.GetMethod( "GetHtml", args.Select(_ => _.GetType()).ToArray() ); }

// メソッドを実行 return (method.Invoke(helper, args) as HelperResult) .ToHtmlString().ToString().Trim(); } ));

var m = new MarkdownSharp.Markdown(); text = m.Transform(text); }

<!DOCTYPE html>

<html lang="ja"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <meta charset="utf-8" /> <title>マイ サイトのタイトル</title> <link href="~/favicon.ico" rel="shortcut icon" type="image/x-icon" /> </head> <body> @Html.Raw(text) </body> </html>

当初、型名->型 を解決するには Type.GetType() でいけると思っていたのだけど、引数として渡す型名にはアセンブリ名やバージョンを含めた完全修飾名が必要みたい。つまり

var _type = Type.GetType("ASP.RubyHelper");

ではだめで、

var _type = Type.GetType("ASP.RubyHelper, ***, Version=1.0.0.0, Culture=neutral, PublicKeyToken=****");

みたいな感じじゃないとダメらしい。ASP.NET の仕組みはイマイチわかっていないのだけれど、裏でコードをコンパイルして、それを実行してるのだと思う。そのアセンブリ名なんて、実行時にはわかんないよね?

しょうがないので、今回は AppDomain にある HelperPage 派生クラス(ヘルパー)を列挙してディクショナリを用意し、型名->型 を解決する方法をとった。ヘルパーに限定したのは、全部突っ込もうとするとキーとなる型名の衝突があって、ToDictionary() が失敗するから。

コマンドを規約通りに検索してみつからない場合は、LinkHelper というリンク生成のためのヘルパーを既定のヘルパーとして呼んでいる。内容はごく簡単なもの。

@helper GetHtml(string url)
{
<a href="@url">@url</a>
}

@helper GetHtml(string url, string title)
{
<a href="@url" title="@title">@title</a>
}

ちなみに、Camelize() は簡易実装なのでみないふりしてほしい(寄り道: string クラスの拡張 - だるろぐ)。あと、エラーチェックがぬるい。たとえば、引数の数をわざと多くするとエラーになる。

実験

とりあえず手元ではだいたい動いたので、試しに NuGet から適当なヘルパーを取得して、それを Markdown から呼び出せるかやってみた。

f:id:daruyanagi:20130224152749p:plain

QRCode ヘルパーは、その名もズバリ、QRCode が生成できるヘルパー。このヘルパーは

@QRCode.Render("http://daruyanagi.net/")

という感じで呼び出すので、残念ながらそのままでは使えない。App_Code/QRCodeHelper.cshtml という補助ヘルパーを別途用意した(NuGet で取得したコードにはあまり手を入れたくないので)。

@helper GetHtml(string data){
<img src="@Href("~/QRCodeImage.cshtml", new{data, scale = 3})" alt="@data" />
}

あとは、[[QRCode|http://daruyanagi.net/]]という記法を Markdown に埋め込むと……

f:id:daruyanagi:20130224153033p:plain

こんな感じになる。GetHtml() メソッドをもつヘルパーだったら、無加工でそのまま利用できる!