AgentSkillsCN

i18n

针对英语与西班牙语翻译,提供国际化开发模式。在新增视图、组件、操作,或任何面向用户的文本时,可选用此方法。内容涵盖全限定翻译键、文件结构、视图键、组件键、操作/契约键、模型键,以及邮件模板的翻译配置。

SKILL.md
--- frontmatter
name: i18n
description: Internationalization patterns for English and Spanish translations. Use when adding new views, components, operations, or any user-facing text. Covers fully-qualified translation keys, file structure, view keys, component keys, operation/contract keys, model keys, and mailer translations.

I18n (English + Spanish)

Dependencies

  • i18n (Rails default)

File Structure

code
config/locales/
  en/
    app.yml           # Shared UI, date/time/number formats
    articles.yml      # Articles domain (views + operations + errors)
    users.yml         # Users domain
    components.yml    # Reusable components
    models.yml        # ActiveRecord models/attributes/enums
  es/
    app.yml
    articles.yml
    users.yml
    components.yml
    models.yml

🔑 Key Principle: Fully-Qualified Keys

Always use fully-qualified translation keys (no relative keys like t(".title")).

Why Fully-Qualified?

  • Explicit: You know exactly where the translation is
  • Grep-able: Easy to find usage with simple text search
  • No magic: No scope resolution confusion
  • Portable: Components/partials work anywhere
  • IDE-friendly: Autocomplete and navigation work better

View Keys (Fully-Qualified)

❌ DON'T use relative keys:

ruby
# app/views/articles/show.rb
h1 { t(".title") }  # WRONG - relative key

✅ DO use fully-qualified keys:

ruby
# app/views/articles/show.rb
class Articles::Show < Views::Base
  def view_template
    h1 { t("articles.show.title") }
    p { t("articles.show.description") }
  end
end
yaml
# config/locales/en/articles.yml
en:
  articles:
    show:
      title: "Article Details"
      description: "View article content"
    index:
      title: "All Articles"
      empty: "No articles yet"
yaml
# config/locales/es/articles.yml
es:
  articles:
    show:
      title: "Detalles del artículo"
      description: "Ver contenido del artículo"
    index:
      title: "Todos los artículos"
      empty: "Aún no hay artículos"

Component Keys (Fully-Qualified)

ruby
# app/components/ui/button.rb
class Components::Ui::Button < Components::Base
  def view_template(&block)
    button(class: "btn") { yield || t("components.ui.button.submit") }
  end
end
yaml
# config/locales/en/components.yml
en:
  components:
    ui:
      button:
        submit: "Submit"
        cancel: "Cancel"
      card:
        read_more: "Read more"

Operation Success/Failure Messages

Group operation messages under the domain:

ruby
# app/concepts/articles/operation/create.rb
module Articles
  module Operation
    class Create < Trailblazer::Operation
      step :create_article
      step :notify_success
      
      def notify_success(ctx, **)
        ctx[:message] = I18n.t("articles.operations.create.success")
        true
      end
    end
  end
end
yaml
# config/locales/en/articles.yml
en:
  articles:
    operations:
      create:
        success: "Article created successfully"
        failure: "Failed to create article"
      update:
        success: "Article updated successfully"
        failure: "Failed to update article"
      destroy:
        success: "Article deleted successfully"
        failure: "Failed to delete article"
yaml
# config/locales/es/articles.yml
es:
  articles:
    operations:
      create:
        success: "Artículo creado exitosamente"
        failure: "Error al crear artículo"
      update:
        success: "Artículo actualizado exitosamente"
        failure: "Error al actualizar artículo"

Error Messages (Domain-Scoped)

Key Principle: Error messages are scoped to domain to allow different messages for same field across models.

Contract Error Messages

ruby
# app/concepts/articles/contract/create.rb
module Articles
  module Contract
    class Create < Reform::Form
      property :title
      property :body
      
      validation do
        required(:title).filled(:str)
        required(:body).filled(:str)
      end
      
      # Custom error messages
      def self.human_attribute_name(attr, options = {})
        I18n.t("articles.attributes.#{attr}", default: attr.to_s.humanize)
      end
    end
  end
end
yaml
# config/locales/en/articles.yml
en:
  articles:
    attributes:
      title: "Title"
      body: "Content"
    errors:
      title_blank: "Article title cannot be blank"
      title_too_short: "Article title must be at least 3 characters"
      title_taken: "Article with this title already exists"
      body_blank: "Content is required"
      body_too_short: "Content must be at least 10 characters"
yaml
# config/locales/es/articles.yml
es:
  articles:
    attributes:
      title: "Título"
      body: "Contenido"
    errors:
      title_blank: "El título del artículo no puede estar vacío"
      title_too_short: "El título debe tener al menos 3 caracteres"
      title_taken: "Ya existe un artículo con este título"
      body_blank: "El contenido es obligatorio"
      body_too_short: "El contenido debe tener al menos 10 caracteres"

Using Errors in Operations

ruby
# In Operation
def validate(ctx, params:, **)
  contract = Articles::Contract::Create.new(Article.new)
  
  if contract.validate(params[:article])
    ctx[:model] = contract.model
    true
  else
    # Transform contract errors to domain-scoped i18n keys
    ctx[:errors] = contract.errors.messages.transform_values do |messages|
      messages.map { |msg| I18n.t("articles.errors.#{msg}") }
    end
    false
  end
end

Model Attributes + Enums

yaml
# config/locales/en/models.yml
en:
  activerecord:
    models:
      article: "Article"
      user: "User"
    attributes:
      article:
        title: "Title"
        body: "Content"
        status: "Status"
      user:
        name: "Name"
        email: "Email"
    enums:
      article:
        status:
          draft: "Draft"
          published: "Published"
          archived: "Archived"
yaml
# config/locales/es/models.yml
es:
  activerecord:
    models:
      article: "Artículo"
      user: "Usuario"
    attributes:
      article:
        title: "Título"
        body: "Contenido"
        status: "Estado"
      user:
        name: "Nombre"
        email: "Correo electrónico"
    enums:
      article:
        status:
          draft: "Borrador"
          published: "Publicado"
          archived: "Archivado"

Use in code:

ruby
Article.model_name.human                    # => "Article"
Article.human_attribute_name(:title)        # => "Title"
Article.human_enum_name(:status, :draft)    # => "Draft"

Date/Time Formatting (Use I18n.l, NOT strftime)

❌ DON'T use strftime:

ruby
article.created_at.strftime("%B %-d, %Y")  # WRONG

✅ DO use I18n.l (localize):

ruby
I18n.l(article.created_at, format: :long)   # RIGHT
I18n.l(article.created_at, format: :short)  # RIGHT
I18n.l(article.created_at.to_date)          # Uses default date format

Define Formats

Define Formats

yaml
# config/locales/en/app.yml
en:
  date:
    formats:
      default: "%Y-%m-%d"
      short: "%b %-d"
      long: "%B %-d, %Y"
      month_year: "%B %Y"
  time:
    formats:
      default: "%a, %d %b %Y %H:%M:%S %z"
      short: "%d %b %H:%M"
      long: "%B %-d, %Y %H:%M"
      time_only: "%H:%M"
yaml
# config/locales/es/app.yml
es:
  date:
    formats:
      default: "%Y-%m-%d"
      short: "%-d %b"
      long: "%-d de %B de %Y"
      month_year: "%B de %Y"
  time:
    formats:
      default: "%a, %d %b %Y %H:%M:%S %z"
      short: "%-d %b %H:%M"
      long: "%-d de %B de %Y %H:%M"
      time_only: "%H:%M"

Usage in Views

ruby
# Phlex view
class Articles::Show < Views::Base
  def view_template
    div do
      h1 { @article.title }
      p(class: "text-sm text-gray-500") do
        plain "Published: "
        plain I18n.l(@article.published_at, format: :long)
      end
      p(class: "text-sm") do
        plain "Last updated: "
        plain I18n.l(@article.updated_at.to_date, format: :short)
      end
    end
  end
end

Benefits of I18n.l:

  • ✅ Respects user's locale automatically
  • ✅ Centralized format definitions
  • ✅ Easy to change formats globally
  • ✅ Proper localization (e.g., "February 10, 2026" vs "10 de febrero de 2026")

Number/Currency Formatting (Use I18n Helpers)

❌ DON'T format manually:

ruby
"$#{price.round(2)}"  # WRONG

✅ DO use number helpers with I18n:

ruby
number_to_currency(price)           # => "$1,234.56" (en) or "€1.234,56" (es)
number_to_percentage(rate)          # => "85.5%"
number_with_delimiter(count)        # => "1,234,567"
number_with_precision(value, precision: 2)  # => "123.46"

Currency Format Definitions

yaml
# config/locales/en/app.yml
en:
  number:
    format:
      delimiter: ","
      separator: "."
      precision: 2
    currency:
      format:
        unit: "$"
        format: "%u%n"
        delimiter: ","
        separator: "."
yaml
# config/locales/es/app.yml
es:
  number:
    format:
      delimiter: "."
      separator: ","
      precision: 2
    currency:
      format:
        unit: "€"
        format: "%n %u"
        delimiter: "."
        separator: ","

Translation Key Conventions Summary

File Organization

code
config/locales/
  en/
    app.yml              # Shared: date/time/number formats, common UI
    {domain}.yml         # Domain-specific: views + operations + errors
    components.yml       # Reusable components
    models.yml          # ActiveRecord translations

Key Structure by Context

Views:

yaml
{domain}:
  {action}:
    key: "Translation"
    
# Example
articles:
  show:
    title: "Article Details"
  index:
    title: "All Articles"

Operations:

yaml
{domain}:
  operations:
    {operation_name}:
      success: "Success message"
      failure: "Failure message"
      
# Example
articles:
  operations:
    create:
      success: "Article created"

Errors:

yaml
{domain}:
  errors:
    {field}_{validation}: "Error message"
    
# Example
articles:
  errors:
    title_blank: "Title cannot be blank"
    title_too_short: "Title is too short"

Components:

yaml
components:
  {namespace}:
    {component}:
      key: "Translation"
      
# Example
components:
  ui:
    button:
      submit: "Submit"

Controllers (Flash Messages)

ruby
class ArticlesController < ApplicationController
  def create
    result = Articles::Operation::Create.call(params: params.to_unsafe_h, current_user: Current.user)
    
    if result.success?
      redirect_to articles_path, notice: I18n.t("articles.operations.create.success")
    else
      flash.now[:alert] = format_errors(result[:errors])
      render :new, status: :unprocessable_entity
    end
  end
  
  private
  
  def format_errors(errors)
    errors.full_messages.join(", ")
  end
end

Pluralization

yaml
# config/locales/en/articles.yml
en:
  articles:
    index:
      count:
        zero: "No articles"
        one: "1 article"
        other: "%{count} articles"
ruby
# Usage
t("articles.index.count", count: @articles.size)

Interpolation

yaml
# config/locales/en/articles.yml
en:
  articles:
    show:
      author: "Written by %{name}"
      published: "Published on %{date}"
ruby
# Usage
t("articles.show.author", name: @article.author.name)
t("articles.show.published", date: I18n.l(@article.published_at, format: :long))

Fallbacks

Enable fallbacks so missing keys fall back to English:

ruby
# config/application.rb
config.i18n.available_locales = %i[en es]
config.i18n.default_locale = :en
config.i18n.fallbacks = [:en]

Best Practices Summary

  1. Always use fully-qualified keys: t("articles.show.title") not t(".title")
  2. Domain-scope errors: articles.errors.title_blank for context-specific messages
  3. Use I18n.l for dates: Never use strftime
  4. Use number helpers: number_to_currency, number_with_delimiter
  5. Both languages required: English + Spanish for every key
  6. Organize by domain: Keep all article translations in articles.yml
  7. Operation messages: Group under {domain}.operations.{op_name}
  8. No hardcoded text: Everything user-facing must be i18n

Anti-Patterns

See anti-patterns.md for complete examples.

❌ Don't:

  • Use relative keys: t(".title")
  • Use strftime: date.strftime("%Y-%m-%d")
  • Hardcode currency: "$#{price}"
  • Share error keys across domains without scoping
  • Mix languages in same file
  • Forget Spanish translations