Railsではてな記法っぽい独自の記法を実装する
弊社のリフォームメディア、ミライエはRailsで開発しています。
システム的にはCMSを実装しているのですが、データ的にはmarkdownとHTMLを混在させて保存しています。これでも回っているのですが、 引用, リンク などは一定のフォーマットで入れたいという要望があります。
そこではてなブックマーク風のオリジナル記法(以下特殊記法)を導入しています。
記法のルール
引用文
[(quote_text)text: 'xxx',refer_url: 'xxx',refer_name: 'xxx']
埋め込みリンク
[(embed_content) url: 'xxx']
というふうに、
[
と]
で囲む- 先頭に
(command)
で種類を定義 - あとはrubyのハッシュ風の引数を列挙する。
これをテキストエリアの中に書いてやれば、レンダリングすると所定のHTMLに変換されてでてくるようにしています。
実装方針
基本は正規表現なのですがこういうのは適当に実装すると確実に負債化するので、以下のサイトを参考にして html-pipeline を使用しました。
特殊記法を含んだ文字列を受け取り、特殊記法部分があらかじめ定義してあるHTMLに変換されて返ってくるようにするのがゴールです。
lib/mitsumo/processors/article_text_processor.rb
まずhtml-pipeline
の使い方として、変換クラスを実装します。今回はArticle
モデルのtext
というカラムを変換する責務を持つので、ArticleTextProcessor
という名前にします。
module Mitsumo module Processors class ArticleTextProcessor DEFAULT_FILTERS = [ Mitsumo::Processors::Filters::MitsumoNotation, Mitsumo::Processors::Filters::Redcarpet, ].freeze DEFAULT_CONTEXT = { }.freeze def initialize(context = {}) @context = DEFAULT_CONTEXT.merge(context) end def call(input, context = {}) HTML::Pipeline.new(filters, @context).call(input, context) end def filters @filters ||= DEFAULT_FILTERS end end end end
Mitsumoというのはプロジェクトネームです。
lib/mitsumo/processors/filters/mitsumo_notation_filter.rb
次にフィルタとして使っているMitsumoNotationFilter
を実装します。1つ1つのフィルタが所定の変換ルールを持ち、ArticleTextProcessor
は一連の変換ルールを束ねる役割を持ちます。
module Mitsumo module Processors module Filters class MitsumoNotationFilter < HTML::Pipeline::TextFilter def call doc = Nokogiri::HTML.fragment(@text) doc.children.each do |node| if node.text =~ Mitsumo::NotationParser::NOTATION_REGEXP html = node.text.gsub(/(#{Mitsumo::NotationParser::NOTATION_REGEXP})/) do NotationRenderer.new($1, @context).html.strip end node.replace Nokogiri::HTML.fragment(html) end end @text = doc.to_s end end end end end
このクラスの処理の流れは
- 流れてきたテキストをnokogiriのオブジェクトにする
- テキストノードに特殊記法を含んでいるDOMを見つける
- 特殊記法から引数を抽出し、
NotationRenderer
に渡してHTMLを返却させる - 最終的にgsubによって特殊記法が
NotationRenderer
が返すHTMLにすり替わる
という流れになっています。
lib/mitsumo/notation_regexp.rb
次に特殊記法を表す正規表現です。
module Mitsumo module NotationRegexp SPACE_OR_BR = /\s+/ ARG_VALUE1 = /'(\\'|[^'])*?'/ #シングルクオートで囲まれたもの ARG_VALUE2 = /"(\\"|[^"])*?"/ #ダブルクオートで囲まれたもの ARG_VALUE3 = /\d+/ #数値 ARG_VALUE = /#{ARG_VALUE1}|#{ARG_VALUE2}|#{ARG_VALUE3}/ ARG_KEY = /[a-z_]+?:/ ARG_KEY_BRACKET = /([a-z_]+?):/ ARG_SET = /#{ARG_KEY}#{SPACE_OR_BR}*#{ARG_VALUE}/ #キーと値のセット ARG_SET_BRACKET = /#{ARG_KEY_BRACKET}#{SPACE_OR_BR}*(#{ARG_VALUE})/ NOTATION_TYPE_LIST = [ 'quote_text', 'quote_image', 'image', 'comment', 'text', 'link', 'header', 'ref_article', 'summary', 'portfolio_item', 'thumbnail', 'embed', ].freeze NOTATION_TYPE = /\(#{Regexp.union(NOTATION_TYPE_LIST)}\)/ #カッコに囲まれた文字列 ARGUMENT_LIST = /#{ARG_SET}(#{SPACE_OR_BR}*,#{SPACE_OR_BR}*#{ARG_SET})*/ NOTATION_REGEXP = /\[#{SPACE_OR_BR}*#{NOTATION_TYPE}#{SPACE_OR_BR}*#{ARGUMENT_LIST}#{SPACE_OR_BR}*\]?/ NOTATION_REGEXP_BRACKETS = /\[#{SPACE_OR_BR}*(#{NOTATION_TYPE})#{SPACE_OR_BR}*(#{ARGUMENT_LIST})#{SPACE_OR_BR}*\]?/ end end
そこそこ複雑ですが、 rubyのハッシュと一緒で
key: 'value'
の形式でカンマ区切り- valueには文字列か数値が来る。数値の場合はクォーテーションは不要
key: value
のあとに改行が来ても良い
という定義になります。
_BRACKETS
というカッコ付きのものは、正規表現でマッチした部分を抽出するのに使います。
lib/mitsumo/notation_renderer.rb
次に特殊記法を用意したHTMLに変換するコードです。
例えば
[(embed_content) id: 100]
という特殊記法であれば、 app/views/notations/embed_content.html.erb
を呼び出します。
module Mitsumo class NotationRenderer include NotationRegexp include NotationParseHelper include ActionView def initialize(text, context = {}) @text = text @context = context end def html @text =~ NOTATION_REGEXP_BRACKETS type = remove_brackets($1) args = parse_argument_list($2) args = args.merge( context: @context, notation: @text, ) if type == 'comment' '' else renderer.render(template: "notations/#{type}", locals: args) end end private def renderer context = Rails.configuration.paths['app/views'] @renderer ||= ViewRenderer.new(context) end class ViewRenderer < ActionView::Base include Rails.application.routes.url_helpers include ImageHelper def default_url_options Rails.application.routes.default_url_options end end end end
ViewRenderer
はコントローラー以外でこのようにテンプレートを処理したいときに必要になります。
lib/mitsumo/notation_parse_helper.rb
最後に上の処理の中で使っている関数をモジュールとして定義しています。
module Mitsumo module NotationParseHelper include NotationRegexp def remove_brackets(type) type.gsub(/\A\(|\)\z/, '') end def parse_argument_list(arg_list) args = {} arg_list.scan(ARG_SET_BRACKET) do |arg_pattern| key = arg_pattern[0].to_sym value = arg_pattern[1] case value when ARG_VALUE1 value = value. gsub(/\A'|'\z/, ''). gsub(/\\'/, '') when ARG_VALUE2 value = value. gsub(/\A"|"\z/, ''). gsub(/\\"/, '') when ARG_VALUE3 value = value.to_i end args[key] = value end args end def make_arguments_list(hash) hash.to_a.map { |k, v| if v.is_a?(Integer) "#{k}: #{v}" else "#{k}: '#{v}'" end }.join(",\n") end end end
あとは用意してあるviewでローカル変数として特殊記法の引数を受け取れるようになります。
どういうときに嬉しいの?
[(quote) text: '引用文', url: 'http://www.yahoo.co.jp']
と書いた時に
<div class='quote-content'> <blockquote><%= text %></blockquote> <p class='referer-url'><%= url %></p> </div>
に変換するような処理が簡単に書けます。
難点
この機能の難点は実装上のハードルがけっこう高いこと、正規表現の理解が難しいこと、そして書き手のユーザーにとっても使用がやや難しい点にあります。
例えば
[(quote) text: '引用文'、url: 'http://www.yahoo.co.jp']
のように本来半角のカンマで区切るべきものを全角にしてしまうと処理できなくなってしまいます。
最近の傾向として一般ユーザーがライティングするようなブログやメディアサイトはcontenteditable
を使ったWYSIWYGに回帰しています。