アトラシエの開発ブログ

株式会社アトラシエのブログです

Railsではてな記法っぽい独自の記法を実装する

弊社のリフォームメディア、ミライエはRailsで開発しています。

miraie.me

システム的にはCMSを実装しているのですが、データ的にはmarkdownとHTMLを混在させて保存しています。これでも回っているのですが、 引用, リンク などは一定のフォーマットで入れたいという要望があります。

そこではてなブックマーク風のオリジナル記法(以下特殊記法)を導入しています。

記法のルール

引用文

[(quote_text)text: 'xxx',refer_url: 'xxx',refer_name: 'xxx']

埋め込みリンク

[(embed_content) url: 'xxx']

というふうに、

  • []で囲む
  • 先頭に(command)で種類を定義
  • あとはrubyのハッシュ風の引数を列挙する。

これをテキストエリアの中に書いてやれば、レンダリングすると所定のHTMLに変換されてでてくるようにしています。

実装方針

基本は正規表現なのですがこういうのは適当に実装すると確実に負債化するので、以下のサイトを参考にして html-pipeline を使用しました。

qiita.com

特殊記法を含んだ文字列を受け取り、特殊記法部分があらかじめ定義してある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に回帰しています。