Template中のURLを自動で賢くアンカーテキストにしたい

まだText::XslateでもText::MicroTemplateでもなくTemplate::Toolkitを主に使っているわけですが。。

やりたいこと

テンプレート中に出てくるURLを自動でアンカーテキストにしたい。

http://example.com/

が自動的に

<a href="http://example.com/">http://example.com/</a>

になってほしい。このはてなダイアリーでURL書くだけでhttp://example.com/みたいに自動でリンクになるように。

Template::Plugin::AutoLink

Template::Plugin::AutoLink - search.cpan.org
というモジュールがあります。これを使えば、期待通りの動作をします。

use Test::More;
use strict;
use warnings;

use Template;
use Template::Plugin::AutoLink;

my $t = Template->new;
$t->process(\'[% USE AutoLink %]');

my $url      = 'http://example.com/';
my $text     = "$url";
my $template = '[% text | auto_link %]';

ok $t->process(\$template, { text => $text }, \my $output);
cmp_ok $output, '=~', qr!<a\s+href="$url">$url</a>!;

done_testing;

$target = "hoge $url fuga"のように無関係な文字列が前後にあっても大抵の場合はURLの部分だけを変換してくれます。

ところがhtmlフィルタと組み合わせると

TTは自動エスケープ機能がないので、よくhtmlフィルタを使用します。

use Test::More;
use strict;
use warnings;

use Template;

my $t = Template->new;

my $text     = '<script>alert("こんにちはこんにちは")</script>';
my $template = '[% text | html %]';

# エスケープされて
# &lt;script&gt;alert(&quot;こんにちはこんにちは&quot;)&lt;/script&gt;
# となる
ok $t->process(\$template, { text => $text }, \my $output);
cmp_ok $output, 'ne', $text;

done_testing;

で、このフィルタとT::P::AutoLinkを併用すると、以下が通らなくなります。

use Test::More;
use strict;
use warnings;

use Template;
use Template::Plugin::AutoLink;

my $t = Template->new;
$t->process(\'[% USE AutoLink %]');

my $url      = 'http://example.com/';
my $text     = "<$url>";
my $template = '[% text | html | auto_link %]';

ok $t->process(\$template, { text => $text }, \my $output);
cmp_ok $output, '=~', qr!<a\s+href="$url">$url</a>!;

done_testing;

URLの末尾にそのまま " や < > が繋がっていると、htmlフィルタを通った後が

&lt;http://example.com/&gt;

のようになり、"&"でクエリパラメータとしてURLが続くような文字列になり、T::P::AutoLinkはそれを検知できずに丸ごとアンカーテキストにしてしまい

'&lt;<a  href="http://example.com/&gt;">http://example.com/&gt;</a>'

という結果になってしまいます。

Text::AutoLink?

では自分でフィルタを作成して使うのが良い?
TTとは別にText::AutoLinkというモジュールがあります。
http://search.cpan.org/~dmaki/Text-AutoLink-0.03000/lib/Text/AutoLink.pm
これは中でウマいことparseしてくれるので、

use Test::More;
use strict;
use warnings;

use Template;
use Text::AutoLink;

my $t = Template->new;

my $url      = 'http://example.com/';
my $text     = qq!"$url"!;
my $template = '[% text | html %]';

ok $t->process(\$template, { text => $text }, \my $output);
$output = Text::AutoLink->new->parse_string($output);
cmp_ok $output, '=~', qr!<a\s+href="$url">$url</a>!;

done_testing;

とやると無事に通ります。が、これは中の仕組み上htmlフィルタでエスケープしたものが元に戻ってしまうようで htmlフィルタと組み合わせるにはちょっと微妙かも。

自前で置換する

正規表現を使って、

  • URLはアンカーテキストに変換
  • それ以外の文字列はすべてHTMLエスケープ

という変換をテキスト全体に施す。

use Test::More;
use strict;
use warnings;

use Regexp::Common 'URI';
use Template::Filters;

my $url  = 'http://example.com/';
my $text = qq!"$url"!;

my $output = autolink_and_escape($text);
cmp_ok $output, '=~', qr!<a\s+href="$url">$url</a>!;

done_testing;


sub autolink_and_escape {
    my $text = shift;

    my $re_uri = qr/($RE{URI}{HTTP})/;
    my @arr = split $re_uri, $text;
    for (0..$#arr) {
        if ($_ % 2) {
            $arr[$_] = qq!<a href="$arr[$_]">$arr[$_]</a>!;
        }
        else {
            $arr[$_] = $Template::Filters::FILTERS->{html}->($arr[$_]);
        }
    }
    return join '', @arr;
}

実行効率は良くないかもしれないけど、このautolink_and_escapeの機能を持つフィルタをTemplate::Pluginで作ってやれば、とりあえずやりたいと思っていたことは実現できそう。

他のTemplateエンジンではどうする?

T::MTやT::Xslateでは、こういうことをやろうとするとどうなるのだろう? デフォルトでhtmlエスケープをするとなると、HTMLタグを敢えて挿入するような処理は向いてなさそうな…?
むしろテンプレートやサーバー側での処理は行わず、表示させてからJavaScriptで変換させるとかいう処理になるのかな…?


はてなダイアリーはどうやってこれを実現しているのだろう?

余談

URLの末尾に"#hoge"とか(フラグメント識別子 fragment identifier というらしい)がついている場合、この部分はRegexp::Common::URIでは無視されてしまう。ので、この部分まで含めたい場合はhttp URI正規表現に付け足してやる必要があるみたい。

    my $hex      = q{[0-9A-Fa-f]};
    my $escaped  = qq{%$hex$hex};
    my $uric     = q{(?:[-_.!~*'()a-zA-Z0-9;/?:@&=+$,]} . qq{|$escaped)};
    my $fragment = qq{$uric*};
    my $re_uri   = qq{$RE{URI}{HTTP}(?:#$fragment)?};

(参考:http://www.din.or.jp/~ohzaki/perl.htm#httpURL)

追記

@さんからコメントいただきました。ありがとうございます! TMTなどの場合 encoded_string()をつかって

use Test::More;
use strict;
use warnings;

use Text::AutoLink;
use Text::MicroTemplate qw/render_mt encoded_string/;

my $url = 'http://example.com/';
my $text = qq!<"$url">!;
my $template = '<?= encoded_string(Text::AutoLink->new->parse_string($_[0])) ?>';

my $output = render_mt($template, $text);
cmp_ok $output, '=~', qr!<a\s+href="$url">$url</a>!;

done_testing;

のようにするとdouble encodedにはならない、とのこと。
あれ、でもこの場合 $outputは

<"<a href="http://example.com/">http://example.com/</a>
">

となってしまう。。 URLはaタグで囲むとして、それ以外の先頭、末尾の'<"', '">' はエスケープしてもらって

&lt;&quot;<a href="http://example.com/">http://example.com/</a>&quot;&gt;

になってほしいのだけど… ソースをみた限りではencoded_string()つかってもそういう処理は難しそうな…! よくわからなくなってきた! ><