Rails Phlex I18n: A VSCode Extension

profile picture

Spencer Miskoviak

June 16, 2024

Photo by Alexandr Podvalny

Rails' dedication to convention over configuration greatly enhances developer productivity. It offers solutions for nearly every aspect of building a modern web application, from low-level database operations to handling frontend translations, known as internationalization (I18n).

However, one area where Rails often feels lacking is in providing a consistent approach to building the frontend. Thankfully, the community has stepped in with various solutions. One such solution is Phlex, a gem that allows developers to create object-oriented HTML components using Ruby constructs. It offers many of the same benefits, APIs, and patterns as writing React components with JSX.

Rails internationalization basics

Let's start with the basics of how Rails handles internationalization. Internationalization is crucial for creating applications that can easily be adapted to different languages and regions.

Rails provides the translate view helper (also aliased at t) which accepts a key that maps to a corresponding value in the translation file.

For example, take the following view with the translation key of hello.world.

# app/views/hello.rb
t("hello.world")

This references the corresponding locale file (in this case, English) and looks up the translation value at this key path.

# config/locales/en.yml
---
en:
  hello:
    world: "Hello world"

In this case, the view will render the string "Hello world".

Another feature of the translation helper is the ability to use relative references. For example, defining the translation key as .world will be relative to the current view hello and functionally equivalent to the above example.

# app/views/hello.rb
t(".world")

Phlex views

Now, let's explore how Phlex views work with Rails.

Phlex most notably differs from traditional Rails view templates by allowing views to be constructed as Ruby classes, providing a more structured and reusable approach using components.

For example, say we have a list of posts on an index route. The component might look like the following.

class Posts::Index < ApplicationView
  def initialize(posts:)
    @posts = posts
  end

  def template
    section do
      render UI::Text.new { t(".title") }

      ul do
        @posts.each do |post|
          render Posts::Post.new(post: post)
        end
      end
    end
  end
end

This component accepts an array of posts, displays a title defined in the translations file, and renders another component for each post.

Since Phlex is an external gem, some Rails helpers and logic don't work out-of-the-box. In this case, the relative translation helper needs to be monkey-patched:

class ApplicationView < ApplicationComponent
  # Patch the `t` translation helper in Phlex components that inherit.
  def t(key, **)
    key = "#{translation_path}#{key}" if key.start_with?(".")
    helpers.translate(key, **)
  end

  # Compute the translation path based on the components class name.
  def translation_path
    self.class.name&.dup.tap do |n|
      n.gsub!("::", ".")
      n.gsub!(/([a-z])([A-Z])/, '\1_\2')
      n.downcase!
    end
  end
end

Recent releases of Phlex Rails now provide the patched translation helper, so this manual patch may no longer be necessary.

Challenges with Translating

While this patch allows the standard Rails translation helpers to work with Phlex, it still requires manual effort when working with translations. Gems like i18n-tasks can handle much of the management and normalization of translations but still leave the tedious day-to-day work on the table.

For example, let's say we are building the component above from scratch and the title text is copied directly from the design.

class Posts::Index < ApplicationView
  def initialize(posts:)
    @posts = posts
  end

  def template
    section do
      render UI::Text.new { "Most Recent Posts" }

      ul do
        @posts.each do |post|
          render Posts::Post.new(post: post)
        end
      end
    end
  end
end

Now, the title text needs to be replaced with the t(".title") helper, and placed into the en.yml file under the key posts.index.title. This isn't hard, but doing this for every string, especially in deeply nested components, can quickly add up.

This is where a custom VSCode extension to bridge this last-mile problem can be helpful.

How it works

Before diving into the details of building the extension, here's a quick demo of the Rails Phlex I18n VSCode extension.

Demo of the rails-phlex-i18n VSCode extension

The raw strings are selected, and the extension extracts them to the translations file. Each translation key defaults to the raw string value but can be quickly edited for more semantic naming. Once a key is translated, it can be hovered to preview the raw string or clicked to navigate to the corresponding key in the translation file.

VSCode API

The majority of the work to build the extension required understanding and leveraging the VSCode API for extensions. Their documentation covers everything, but finding exactly the API or pattern you're looking for can be challenging.

I started with VSCode's Your First Extension documentation to understand the basics. Then, I described what I wanted to build to ChatGPT, which provided a skeleton for the plugin. Although it wasn't always correct, it highlighted the APIs, which I could then cross-reference with the documentation. This is one area I find ChatGPT the most helpful—when new to a topic and needing to quickly build a basic mental model.

Here are some functions this extension leveraged:

  • Commands to execute via the Command Palette
  • Hover tooltips with markdown support and links to other commands
  • Document and editor references to get the current selection(s), modify the editor, and open documents
  • Prompts for user input and displaying toast messages

Integrating with VSCode was only the first part of the puzzle.

YAML AST

The other significant task was integrating with Rails conventions, specifically the translations file.

Translations are stored in a YAML file, so this extension needed to read and write to this file. Initially, I used js-yaml, which handled all use cases except opening a specific key in the YAML file. Identifying the exact line where a key was defined wasn't straightforward without a parser that included this metadata.

The yaml package includes a line counter that provides the exact line and column of each node. This package was also used to modify the translations object and dump it back to the file, which then gets formatted with i18n-tasks normalize to ensure consistency.

Conclusion

If this VSCode extension sounds useful in your day-to-day development, give it a try! It currently has many assumptions baked in, so it may require refactoring to make it more generic or configurable.

This was the first VSCode extension I've written from scratch, and I found the GPT-driven development approach helpful for exploring and quickly navigating an unknown space. While a VSCode extension isn't the right solution for every problem, in this case, it bridged a gap that couldn't be filled at the framework or gem layer. It felt like the right tool for the job!

Tags:

course

Practical Abstract Syntax Trees

Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.

Check out the course