Skip to content

Shopify LiquidテンプレートエンジンをRailsで活用する

はじめに

Liquid は、Shopify が開発したセキュアで柔軟なテンプレートエンジンです。Railsの標準ERBテンプレートとは異なり、Liquidはユーザー生成コンテンツや動的なテンプレートに適しており、セキュリティを重視したテンプレート環境を提供します。

Liquidの特徴

  • セキュア: 任意のRubyコードの実行を防ぐ
  • 柔軟: カスタムフィルターとタグの追加が容易
  • ユーザーフレンドリー: 非プログラマーでも理解しやすい構文
  • パフォーマンス: 効率的なレンダリング
  • 多言語対応: 国際化に対応

セットアップと基本設定

1. インストール

ruby
# Gemfile
gem 'liquid'
gem 'liquid-rails'  # Rails統合用

# または独自実装の場合
gem 'liquid', '~> 5.4'
bash
bundle install

2. Rails統合の設定

ruby
# config/application.rb
class Application < Rails::Application
  # Liquidテンプレートエンジンの有効化
  config.liquid = ActiveSupport::OrderedOptions.new
  config.liquid.enabled = true
  config.liquid.template_extensions = ['liquid']
end

# config/initializers/liquid.rb
Rails.application.configure do
  # Liquidの基本設定
  config.liquid.strict_variables = Rails.env.production?
  config.liquid.strict_filters = Rails.env.production?
  config.liquid.error_mode = Rails.env.production? ? :warn : :strict
  
  # カスタムフィルターの登録
  config.liquid.filters = [
    ApplicationLiquidFilters,
    DateTimeLiquidFilters,
    HtmlLiquidFilters
  ]
  
  # カスタムタグの登録
  config.liquid.tags = {
    'render_partial' => RenderPartialTag,
    'cache_block' => CacheBlockTag,
    'feature_flag' => FeatureFlagTag
  }
end

3. コントローラーでの利用

ruby
# app/controllers/templates_controller.rb
class TemplatesController < ApplicationController
  def render_liquid_template
    @template_content = params[:template] || default_template
    @template_data = gather_template_data
    
    begin
      liquid_template = Liquid::Template.parse(@template_content)
      @rendered_content = liquid_template.render(@template_data, 
        strict_variables: true,
        strict_filters: true
      )
    rescue Liquid::Error => e
      @error_message = "Template Error: #{e.message}"
      render :template_error and return
    end
    
    respond_to do |format|
      format.html
      format.json { render json: { content: @rendered_content } }
    end
  end
  
  private
  
  def gather_template_data
    {
      'user' => current_user&.as_liquid,
      'products' => Product.featured.as_liquid,
      'site' => {
        'name' => 'My Store',
        'url' => request.base_url,
        'currency' => 'USD'
      },
      'page' => {
        'title' => 'Product Catalog',
        'url' => request.url,
        'published_at' => Time.current.iso8601
      }
    }
  end
  
  def default_template
    <<~LIQUID
      <h1>Welcome to {{ site.name }}</h1>
      {% if user %}
        <p>Hello, {{ user.name }}!</p>
      {% else %}
        <p>Please <a href="/login">sign in</a> to continue.</p>
      {% endif %}
      
      <h2>Featured Products</h2>
      {% for product in products %}
        <div class="product">
          <h3>{{ product.name }}</h3>
          <p>{{ product.description | truncate: 100 }}</p>
          <p class="price">{{ product.price | money }}</p>
        </div>
      {% endfor %}
    LIQUID
  end
end

カスタムフィルターの実装

1. 基本的なフィルター

ruby
# app/liquid/application_liquid_filters.rb
module ApplicationLiquidFilters
  # 通貨フォーマット
  def money(input, currency = 'USD')
    return '' if input.blank?
    
    amount = input.to_f
    symbol = currency_symbol(currency)
    "#{symbol}#{format('%.2f', amount)}"
  end
  
  # 日本語の相対時間
  def time_ago_in_japanese(input)
    return '' if input.blank?
    
    time = parse_time(input)
    return '' unless time
    
    distance = Time.current - time
    
    case distance
    when 0..59
      "#{distance.to_i}秒前"
    when 60..3599
      "#{(distance / 60).to_i}分前"
    when 3600..86399
      "#{(distance / 3600).to_i}時間前"
    else
      "#{(distance / 86400).to_i}日前"
    end
  end
  
  # HTMLの安全なトランケート
  def truncate_html(input, length = 100)
    return '' if input.blank?
    
    text = strip_tags(input.to_s)
    text.length > length ? "#{text[0, length]}..." : text
  end
  
  # 画像URLの生成
  def image_url(input, size = 'medium')
    return '' if input.blank?
    
    base_url = Rails.application.routes.url_helpers.root_url
    sizes = {
      'small' => '150x150',
      'medium' => '300x300',
      'large' => '600x600'
    }
    
    "#{base_url}images/#{input}?size=#{sizes[size] || size}"
  end
  
  private
  
  def currency_symbol(currency)
    {
      'USD' => '$',
      'EUR' => '€',
      'JPY' => '¥',
      'GBP' => '£'
    }[currency] || currency
  end
  
  def parse_time(input)
    case input
    when Time, DateTime
      input
    when String
      Time.parse(input) rescue nil
    else
      nil
    end
  end
  
  def strip_tags(html)
    html.gsub(/<[^>]*>/, '')
  end
end

# Liquidフィルターとして登録
Liquid::Template.register_filter(ApplicationLiquidFilters)

2. 高度なフィルター

ruby
# app/liquid/html_liquid_filters.rb
module HtmlLiquidFilters
  # マークダウンの変換
  def markdown(input)
    return '' if input.blank?
    
    require 'redcarpet'
    renderer = Redcarpet::Render::HTML.new(
      filter_html: true,
      no_links: false,
      no_images: false,
      with_toc_data: true
    )
    markdown = Redcarpet::Markdown.new(renderer)
    markdown.render(input.to_s).html_safe
  end
  
  # シンタックスハイライト
  def highlight(input, language = 'ruby')
    return '' if input.blank?
    
    require 'rouge'
    formatter = Rouge::Formatters::HTML.new
    lexer = Rouge::Lexer.find(language) || Rouge::Lexers::PlainText
    formatter.format(lexer.lex(input.to_s)).html_safe
  end
  
  # レスポンシブ画像の生成
  def responsive_image(input, alt_text = '', classes = '')
    return '' if input.blank?
    
    srcset = %w[1x 2x 3x].map do |density|
      "#{image_url(input, density)} #{density}"
    end.join(', ')
    
    %(<img src="#{image_url(input)}" 
           srcset="#{srcset}" 
           alt="#{ERB::Util.html_escape(alt_text)}" 
           class="#{classes}" 
           loading="lazy">).html_safe
  end
  
  # ソーシャルメディア埋め込み
  def embed_tweet(tweet_url)
    return '' if tweet_url.blank?
    
    tweet_id = extract_tweet_id(tweet_url)
    return '' unless tweet_id
    
    %(<blockquote class="twitter-tweet">
        <a href="#{tweet_url}"></a>
      </blockquote>
      <script async src="https://platform.twitter.com/widgets.js"></script>).html_safe
  end
  
  private
  
  def extract_tweet_id(url)
    url.match(/status\/(\d+)/)?.[1]
  end
end

カスタムタグの実装

1. 基本的なタグ

ruby
# app/liquid/tags/render_partial_tag.rb
class RenderPartialTag < Liquid::Tag
  def initialize(tag_name, markup, tokens)
    super
    @partial_name = markup.strip.gsub(/['"]/, '')
  end
  
  def render(context)
    # Railsのパーシャルをレンダリング
    controller = context.registers[:controller]
    return '' unless controller
    
    begin
      controller.render_to_string(
        partial: @partial_name,
        locals: context.scopes.last || {}
      )
    rescue => e
      Rails.logger.error "Liquid partial error: #{e.message}"
      Rails.env.development? ? "Error: #{e.message}" : ''
    end
  end
end

# app/liquid/tags/cache_block_tag.rb
class CacheBlockTag < Liquid::Block
  def initialize(tag_name, markup, tokens)
    super
    @cache_key = markup.strip.gsub(/['"]/, '')
    @expires_in = 1.hour
  end
  
  def render(context)
    cache_key = Liquid::Template.parse(@cache_key).render(context)
    
    Rails.cache.fetch("liquid_cache:#{cache_key}", expires_in: @expires_in) do
      super(context)
    end
  end
end

# app/liquid/tags/feature_flag_tag.rb
class FeatureFlagTag < Liquid::Block
  def initialize(tag_name, markup, tokens)
    super
    @flag_name = markup.strip.gsub(/['"]/, '')
  end
  
  def render(context)
    user = context['user']
    flag_enabled = FeatureFlag.enabled?(@flag_name, user)
    
    flag_enabled ? super(context) : ''
  end
end

# 登録
Liquid::Template.register_tag('render_partial', RenderPartialTag)
Liquid::Template.register_tag('cache', CacheBlockTag)
Liquid::Template.register_tag('feature_flag', FeatureFlagTag)

2. 条件分岐タグ

ruby
# app/liquid/tags/device_tag.rb
class DeviceTag < Liquid::Block
  def initialize(tag_name, markup, tokens)
    super
    @device_type = markup.strip.downcase
  end
  
  def render(context)
    request = context.registers[:request]
    return '' unless request
    
    user_agent = request.user_agent.to_s.downcase
    
    is_target_device = case @device_type
    when 'mobile'
      mobile_device?(user_agent)
    when 'tablet'
      tablet_device?(user_agent)
    when 'desktop'
      !mobile_device?(user_agent) && !tablet_device?(user_agent)
    else
      false
    end
    
    is_target_device ? super(context) : ''
  end
  
  private
  
  def mobile_device?(user_agent)
    user_agent.match?(/mobile|android|iphone|ipod|blackberry|windows phone/)
  end
  
  def tablet_device?(user_agent)
    user_agent.match?(/ipad|android(?!.*mobile)|tablet/)
  end
end

Liquid::Template.register_tag('device', DeviceTag)

セキュリティ実装

1. 安全なテンプレート実行

ruby
# app/services/liquid_template_service.rb
class LiquidTemplateService
  MAX_RENDER_TIME = 5.seconds
  MAX_TEMPLATE_SIZE = 100.kilobytes
  
  def self.render_safely(template_content, data = {}, options = {})
    new(template_content, data, options).render
  end
  
  def initialize(template_content, data = {}, options = {})
    @template_content = template_content
    @data = data
    @options = default_options.merge(options)
    
    validate_template_size!
  end
  
  def render
    template = parse_template
    
    # タイムアウト付きで実行
    Timeout.timeout(MAX_RENDER_TIME) do
      template.render(@data, @options)
    end
  rescue Liquid::Error => e
    handle_liquid_error(e)
  rescue Timeout::Error
    handle_timeout_error
  rescue => e
    handle_unexpected_error(e)
  end
  
  private
  
  def parse_template
    Liquid::Template.parse(@template_content, @options)
  end
  
  def default_options
    {
      strict_variables: true,
      strict_filters: true,
      error_mode: :strict,
      registers: {
        controller: Current.controller,
        request: Current.request
      }
    }
  end
  
  def validate_template_size!
    if @template_content.bytesize > MAX_TEMPLATE_SIZE
      raise SecurityError, "Template size exceeds maximum allowed size"
    end
  end
  
  def handle_liquid_error(error)
    Rails.logger.warn "Liquid template error: #{error.message}"
    
    if Rails.env.production?
      "Template rendering failed"
    else
      "Liquid Error: #{error.message}"
    end
  end
  
  def handle_timeout_error
    Rails.logger.error "Liquid template timeout"
    "Template rendering timed out"
  end
  
  def handle_unexpected_error(error)
    Rails.logger.error "Unexpected liquid error: #{error.message}"
    Rails.logger.error error.backtrace.join("\n")
    
    "Template rendering failed"
  end
end

2. ユーザー入力のサニタイゼーション

ruby
# app/liquid/secure_liquid_filters.rb
module SecureLiquidFilters
  # HTMLエスケープ
  def escape(input)
    ERB::Util.html_escape(input.to_s)
  end
  
  # URLエスケープ
  def url_escape(input)
    CGI.escape(input.to_s)
  end
  
  # JSONエスケープ
  def json_escape(input)
    input.to_json
  end
  
  # スクリプトタグの除去
  def strip_scripts(input)
    input.to_s.gsub(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/mi, '')
  end
  
  # 許可されたHTMLタグのみ保持
  def sanitize_html(input, allowed_tags = %w[p br strong em ul ol li])
    require 'sanitize'
    
    Sanitize.fragment(input.to_s, 
      :elements => allowed_tags,
      :remove_contents => %w[script],
      :whitespace => :remove
    )
  end
end

Liquid::Template.register_filter(SecureLiquidFilters)

実用的な使用例

1. Eコマースサイトのテンプレート

liquid
<!-- 商品一覧テンプレート -->
<div class="product-grid">
  {% for product in products %}
    <div class="product-card" data-product-id="{{ product.id }}">
      {% if product.featured_image %}
        {{ product.featured_image | responsive_image: product.name, 'product-image' }}
      {% endif %}
      
      <h3 class="product-title">
        <a href="/products/{{ product.slug }}">{{ product.name }}</a>
      </h3>
      
      <p class="product-description">
        {{ product.description | truncate_html: 150 }}
      </p>
      
      <div class="product-price">
        {% if product.compare_at_price > product.price %}
          <span class="original-price">{{ product.compare_at_price | money }}</span>
          <span class="sale-price">{{ product.price | money }}</span>
          <span class="discount-badge">
            {{ product.compare_at_price | minus: product.price | divided_by: product.compare_at_price | times: 100 | round }}% OFF
          </span>
        {% else %}
          <span class="price">{{ product.price | money }}</span>
        {% endif %}
      </div>
      
      <div class="product-actions">
        {% if product.available %}
          <button class="btn btn-primary add-to-cart" 
                  data-product-id="{{ product.id }}"
                  data-variant-id="{{ product.default_variant.id }}">
            カートに追加
          </button>
        {% else %}
          <button class="btn btn-secondary" disabled>
            売り切れ
          </button>
        {% endif %}
      </div>
      
      {% if product.tags.size > 0 %}
        <div class="product-tags">
          {% for tag in product.tags %}
            <span class="tag">{{ tag }}</span>
          {% endfor %}
        </div>
      {% endif %}
    </div>
  {% else %}
    <p class="no-products">商品が見つかりませんでした。</p>
  {% endfor %}
</div>

<!-- ページネーション -->
{% if products.pages > 1 %}
  <nav class="pagination">
    {% if products.previous_page %}
      <a href="?page={{ products.previous_page }}" class="prev">前へ</a>
    {% endif %}
    
    {% for page in (1..products.pages) %}
      {% if page == products.current_page %}
        <span class="current">{{ page }}</span>
      {% else %}
        <a href="?page={{ page }}">{{ page }}</a>
      {% endif %}
    {% endfor %}
    
    {% if products.next_page %}
      <a href="?page={{ products.next_page }}" class="next">次へ</a>
    {% endif %}
  </nav>
{% endif %}

2. ブログテンプレート

liquid
<!-- ブログ記事テンプレート -->
<article class="blog-post">
  <header class="post-header">
    <h1 class="post-title">{{ post.title }}</h1>
    
    <div class="post-meta">
      <time class="post-date" datetime="{{ post.published_at | date: '%Y-%m-%d' }}">
        {{ post.published_at | date: '%Y年%m月%d日' }}
      </time>
      
      {% if post.author %}
        <span class="post-author">
          by <a href="/authors/{{ post.author.slug }}">{{ post.author.name }}</a>
        </span>
      {% endif %}
      
      {% if post.reading_time %}
        <span class="reading-time">{{ post.reading_time }}分で読める</span>
      {% endif %}
    </div>
    
    {% if post.featured_image %}
      <div class="post-featured-image">
        {{ post.featured_image | responsive_image: post.title, 'featured-image' }}
      </div>
    {% endif %}
  </header>
  
  <div class="post-content">
    {% if post.excerpt %}
      <div class="post-excerpt">
        {{ post.excerpt }}
      </div>
    {% endif %}
    
    <div class="post-body">
      {{ post.content | markdown }}
    </div>
  </div>
  
  <footer class="post-footer">
    {% if post.tags.size > 0 %}
      <div class="post-tags">
        <span class="tags-label">タグ:</span>
        {% for tag in post.tags %}
          <a href="/tags/{{ tag.slug }}" class="tag">{{ tag.name }}</a>
        {% endfor %}
      </div>
    {% endif %}
    
    <div class="post-share">
      <span class="share-label">共有:</span>
      <a href="https://twitter.com/intent/tweet?text={{ post.title | url_escape }}&url={{ post.url | url_escape }}" 
         class="share-twitter" target="_blank">Twitter</a>
      <a href="https://www.facebook.com/sharer/sharer.php?u={{ post.url | url_escape }}" 
         class="share-facebook" target="_blank">Facebook</a>
    </div>
  </footer>
</article>

<!-- 関連記事 -->
{% if related_posts.size > 0 %}
  <aside class="related-posts">
    <h3>関連記事</h3>
    <div class="related-posts-grid">
      {% for related_post in related_posts limit: 3 %}
        <article class="related-post">
          {% if related_post.featured_image %}
            <a href="{{ related_post.url }}" class="related-post-image">
              {{ related_post.featured_image | image_url: 'small' | img_tag: related_post.title }}
            </a>
          {% endif %}
          
          <div class="related-post-content">
            <h4 class="related-post-title">
              <a href="{{ related_post.url }}">{{ related_post.title }}</a>
            </h4>
            <time class="related-post-date">
              {{ related_post.published_at | time_ago_in_japanese }}
            </time>
          </div>
        </article>
      {% endfor %}
    </div>
  </aside>
{% endif %}

パフォーマンス最適化

1. キャッシュ戦略

ruby
# app/controllers/liquid_controller.rb
class LiquidController < ApplicationController
  before_action :set_cache_headers
  
  def render_template
    cache_key = generate_cache_key
    
    @rendered_content = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
      LiquidTemplateService.render_safely(
        template_content,
        template_data,
        registers: liquid_registers
      )
    end
    
    respond_to do |format|
      format.html
      format.json { render json: { content: @rendered_content } }
    end
  end
  
  private
  
  def generate_cache_key
    [
      'liquid_template',
      params[:template_id],
      current_user&.cache_key_with_version,
      template_data.hash
    ].compact.join('/')
  end
  
  def set_cache_headers
    expires_in 1.hour, public: current_user.nil?
  end
  
  def liquid_registers
    {
      controller: self,
      request: request,
      user: current_user
    }
  end
end

2. テンプレートの事前コンパイル

ruby
# lib/tasks/liquid_precompile.rake
namespace :liquid do
  desc "Precompile liquid templates"
  task precompile: :environment do
    puts "Precompiling Liquid templates..."
    
    LiquidTemplate.find_each do |template|
      begin
        compiled = Liquid::Template.parse(template.content)
        
        # コンパイル済みテンプレートをキャッシュ
        Rails.cache.write(
          "compiled_liquid_template:#{template.id}",
          compiled,
          expires_in: 1.week
        )
        
        puts "✓ Compiled template: #{template.name}"
      rescue Liquid::Error => e
        puts "✗ Failed to compile template #{template.name}: #{e.message}"
      end
    end
    
    puts "Precompilation completed."
  end
end

まとめ

Shopify LiquidをRailsアプリケーションに統合することで、セキュアで柔軟なテンプレートシステムを構築できます。特にユーザー生成コンテンツや動的なテンプレートが必要なアプリケーションでは、その安全性と拡張性が大きな価値を提供します。

主要な利点:

  • セキュアなテンプレート実行環境
  • カスタムフィルターとタグによる拡張性
  • ユーザーフレンドリーな構文
  • 高いパフォーマンス
  • 豊富なエコシステム

適切な実装とセキュリティ対策を行うことで、Liquidは強力なテンプレートソリューションとして機能します。

AI が自動生成した技術記事をまとめたテックブログ