Skip to content

Instantly share code, notes, and snippets.

@ryanb
Created April 20, 2024 05:21
Show Gist options
  • Save ryanb/cc41ea5b0ebcfc5cb236b1c7e03527c1 to your computer and use it in GitHub Desktop.
Save ryanb/cc41ea5b0ebcfc5cb236b1c7e03527c1 to your computer and use it in GitHub Desktop.
Simple GetText solution for JavaScript for use with translation.io
<%# app/views/layouts/application.html.erb %>
...
<%= javascript_include_tag "locales/#{I18n.locale}", nonce: true %>
# lib/translation/dump_js_gettext_keys_step.rb
require_relative "js_extractor"
# Based on https://github.com/translation/rails/blob/master/lib/translation_io/client/base_operation/dump_markup_gettext_keys_step.rb#L54
module Translation
module DumpJsGettextKeysStep
def self.run(source_files:, file_type:)
return if source_files.empty?
puts "Extracting Gettext entries from #{file_type} files."
FileUtils.mkdir_p(File.join("tmp", "translation"))
extracted_gettext_entries(source_files:).each_with_index do |entry, index|
file_path =
File.join("tmp", "translation", "#{file_type}-gettext-#{index.to_s.rjust(8, "0")}.rb")
File.open(file_path, "w") do |file|
file.puts "def fake"
file.puts " #{entry}"
file.puts "end"
end
# can happen sometimes if gettext parsing is wrong
remove_file_if_syntax_invalid(file_path:, entry:)
end
end
def self.remove_file_if_syntax_invalid(file_path:, entry:)
if `ruby -c #{file_path} 2>/dev/null`.empty? # returns 'Syntax OK' if syntax valid
puts ""
puts "Warning - #{file_type} Gettext parsing failed: #{entry}"
puts " This entry will be ignored until you fix it. Please note that"
puts " this warning can sometimes be caused by complex interpolated strings."
puts ""
FileUtils.rm(file_path)
end
end
def self.extracted_gettext_entries(source_files:)
entries = []
source_files.each do |file_path|
source = File.read(file_path)
entries += Translation::JsExtractor.extract_ruby_lines(source)
end
puts "#{entries.size} entries found"
entries
end
end
end
# lib/translation/finish_js_sync.rb
require_relative "prepare_js_sync"
# This generates JS files for each locale based on the GetText translations (.mo files)
module Translation
module FinishJsSync
DESTINATION = "#{Rails.root}/app/assets/javascripts/locales"
def self.run
gettext_entries = parse_gettext_entries
locales = i18n_locales - ["en"]
locales.each do |locale|
mo = get_mo(locale:)
File.write("#{DESTINATION}/#{locale}.js", generate_js(mo:, gettext_entries:))
end
end
def self.get_mo(locale:)
gettext_locale = locale.gsub("-", "_")
GetText::MO.open("#{Rails.root}/config/locales/gettext/#{gettext_locale}/LC_MESSAGES/app.mo")
end
def self.parse_gettext_entries
source_files = Dir[PrepareJsSync::JS_PATH] + Dir[PrepareJsSync::SVELTE_PATH]
source_files.reduce([]) do |entries, file_path|
source = File.read(file_path)
entries + Translation::JsExtractor.extract(source)
end
end
def self.generate_js(mo:, gettext_entries:)
translations = {}
mo.each do |key, value|
next if key == value
translations[key] = value if js_translation?(text: key, gettext_entries:)
end
"window.translations = #{translations.to_json};"
end
def self.js_translation?(text:, gettext_entries:)
gettext_entries.each do |entry|
method_name, *rest = entry
case method_name
when "_"
return true if rest.sole == text
else
raise "Unimplemented gettext method: #{method_name}"
end
end
return false
end
def self.i18n_locales
I18n.backend.translations(do_init: true).keys.map(&:to_s)
end
end
end
# spec/translation/finish_js_sync_spec.rb
require "rails_helper"
require "#{Rails.root}/lib/translation/finish_js_sync"
RSpec.describe Translation::FinishJsSync do
describe ".generate_js" do
it "returns js code matching GetText entries" do
expect(
described_class.generate_js(mo: {"Hello" => "Bonjour"}, gettext_entries: [%w[_ Hello]]),
).to eq('window.translations = {"Hello":"Bonjour"};')
end
it "skips translations that are the same" do
expect(
described_class.generate_js(mo: {"Hello" => "Hello"}, gettext_entries: [%w[_ Hello]]),
).to eq("window.translations = {};")
end
end
end
# lib/translation/js_extractor.rb
# Based on https://github.com/translation/rails/blob/master/lib/translation_io/extractor.rb
module Translation
module JsExtractor
ARG = '\s*(?:"(.*?)"|\'(.*?)\'|`(.*?)`)\s*'
REGEXP_S = '(_)\(' + ARG + "[,)]"
REGEXP_P = '(p_)\(' + ARG + "," + ARG + "[,)]"
REGEXP_N = '(n_)\(' + ARG + "," + ARG + ","
REGEXP_PN = '(pn_)\(' + ARG + "," + ARG + "," + ARG + ","
GETTEXT_REGEXP =
Regexp.new("(?:" + REGEXP_PN + "|" + REGEXP_N + "|" + REGEXP_P + "|" + REGEXP_S + ")")
def self.extract(source)
source.scan(GETTEXT_REGEXP).map(&:compact)
end
def self.extract_ruby_lines(source)
extract(source).map { |parts| extract_ruby_line(parts) }
end
def self.extract_ruby_line(parts)
method_name, *args = parts
args = args.map { |arg| arg.gsub("'", "\\\\'") }
"#{method_name}('#{args.join("', '")}')"
end
end
end
# spec/translation/js_extractor_spec.rb
require "rails_helper"
require "#{Rails.root}/lib/translation/js_extractor"
RSpec.describe Translation::JsExtractor do
describe ".extract" do
it "returns extracted gettext" do
expect(described_class.extract("_(\"Hello\")")).to eq([%w[_ Hello]])
expect(described_class.extract("_('Hello')")).to eq([%w[_ Hello]])
expect(described_class.extract("_(`Hello`)")).to eq([%w[_ Hello]])
expect(described_class.extract("_(`Hello`)")).to eq([%w[_ Hello]])
expect(described_class.extract("_(`Hello`, {foo: bar()})")).to eq([%w[_ Hello]])
expect(described_class.extract("p_(`1`, `2`)")).to eq([%w[p_ 1 2]])
expect(described_class.extract("p_(`1`, `2`, {foo: bar()})")).to eq([%w[p_ 1 2]])
expect(described_class.extract("n_(`1`, `2`, 3)")).to eq([%w[n_ 1 2]])
expect(described_class.extract("n_(`1`, `2`, 3, {foo: bar()})")).to eq([%w[n_ 1 2]])
expect(described_class.extract("pn_(`1`, `2`, `3`, 4)")).to eq([%w[pn_ 1 2 3]])
expect(described_class.extract("pn_(`1`, `2`, `3`, 4, {foo: bar()})")).to eq([%w[pn_ 1 2 3]])
end
end
describe ".extract_ruby_line" do
it "returns extracted gettext as ruby" do
expect(described_class.extract_ruby_line(%w[_ Hello])).to eq("_('Hello')")
expect(described_class.extract_ruby_line(%w[_ He'llo])).to eq("_('He\\'llo')")
expect(described_class.extract_ruby_line(%w[p_ 1 2])).to eq("p_('1', '2')")
expect(described_class.extract_ruby_line(%w[n_ 1 2])).to eq("n_('1', '2')")
expect(described_class.extract_ruby_line(%w[pn_ 1 2 3])).to eq("pn_('1', '2', '3')")
end
end
end
// app/assets/config/manifest.js
// ...
//= link_tree ../javascripts/locales
# lib/translation/prepare_js_sync.rb
require_relative "dump_js_gettext_keys_step"
module Translation
module PrepareJsSync
JS_PATH = "app/frontend/javascripts/**/*.js"
SVELTE_PATH = "app/frontend/javascripts/**/*.svelte"
def self.run
DumpJsGettextKeysStep.run(source_files: Dir[JS_PATH], file_type: "js")
DumpJsGettextKeysStep.run(source_files: Dir[SVELTE_PATH], file_type: "svelte")
end
end
end
import {_} from "./translate";
// ...
_("Hello %{name}!", {name: "Bob"});
window.translations = {};
export function _(text, args = {}) {
const translation = window.translations[text];
if (translation) {
return interpolate(translation, args);
} else {
return interpolate(text, args);
}
}
export function p_(context, text, args = {}) {
const translation = window.translations[`p_${context}__${text}`];
if (translation) {
return interpolate(translation, args);
} else {
return interpolate(text, args);
}
}
export function n_(oneText, manyText, count, args = {}) {
const translation = window.translations[`n_${oneText}__${manyText}`];
if (translation) {
return interpolate(count === 1 ? translation[0] : translation[1], args);
} else {
return interpolate(count === 1 ? oneText : manyText, args);
}
}
export function pn_(context, oneText, manyText, count, args = {}) {
const translation = window.translations[`pn_${context}__${oneText}__${manyText}`];
if (translation) {
return interpolate(count === 1 ? translation[0] : translation[1], args);
} else {
return interpolate(count === 1 ? oneText : manyText, args);
}
}
function interpolate(text, args) {
return text.replace(/%\{(\w+)\}/g, (_match, key) => {
if (!args.hasOwnProperty(key)) {
throw `No argument provided for %{${key}}`;
}
return args[key];
});
}
import {_, p_, n_, pn_} from "javascripts/common/translate";
describe("Translate", function () {
afterEach(function () {
window.translations = {};
});
describe("_", function () {
it("returns given text", function () {
expect(_("Hello")).toEqual("Hello");
});
it("interpolates argument", function () {
expect(_("Hello %{name}", {name: "Bob"})).toEqual("Hello Bob");
});
it("throws error if argument not found", function () {
expect(() => _("Hello %{name}")).toThrow("No argument provided for %{name}");
});
it("uses translation", function () {
window.translations["Hello"] = "Bonjour";
expect(_("Hello")).toEqual("Bonjour");
});
});
describe("p_", function () {
it("returns given text ignoring context", function () {
expect(p_("context", "Foo")).toEqual("Foo");
});
it("interpolates argument", function () {
expect(p_("context", "Hello %{name}", {name: "Bob"})).toEqual("Hello Bob");
});
it("uses translation", function () {
window.translations["p_context__Hello"] = "Bonjour";
expect(p_("context", "Hello")).toEqual("Bonjour");
});
});
describe("n_", function () {
it("returns text for one", function () {
expect(n_("one", "many", 1)).toEqual("one");
});
it("returns text for many", function () {
expect(n_("one", "many", 2)).toEqual("many");
});
it("interpolates argument", function () {
expect(n_("one", "many %{count}", 2, {count: 2})).toEqual("many 2");
});
it("uses translation", function () {
window.translations["n_one__many"] = ["un", "beaucoup"];
expect(n_("one", "many", 2)).toEqual("beaucoup");
});
});
describe("pn_", function () {
it("returns text for one", function () {
expect(pn_("context", "one", "many", 1)).toEqual("one");
});
it("returns text for many", function () {
expect(pn_("context", "one", "many", 2)).toEqual("many");
});
it("interpolates argument", function () {
expect(pn_("context", "one", "many %{count}", 2, {count: 2})).toEqual("many 2");
});
it("uses translation", function () {
window.translations["pn_context__one__many"] = ["un", "beaucoup"];
expect(pn_("context", "one", "many", 2)).toEqual("beaucoup");
});
});
});
# lib/tasks/translation.rake
namespace :translation do
desc "Prepare js for syncing"
task prepare_js: :environment do
require_relative "../translation/prepare_js_sync.rb"
Translation::PrepareJsSync.run
end
desc "Finish js after syncing"
task finish_js: :environment do
require_relative "../translation/finish_js_sync.rb"
Translation::FinishJsSync.run
end
end
Rake::Task["translation:sync"].enhance(["translation:prepare_js"]) do
Rake::Task["translation:finish_js"].execute
end
Rake::Task["translation:sync_and_purge"].enhance(["translation:prepare_js"]) do
Rake::Task["translation:finish_js"].execute
end
Rake::Task["translation:sync_and_show_purgeable"].enhance(["translation:prepare_js"]) do
Rake::Task["translation:finish_js"].execute
end
@ryanb
Copy link
Author

ryanb commented Apr 20, 2024

This is my solution for i18n in JavaScript in a Rails app. I've tried i18n-js but I prefer GetText over key-based i18n. I've also tried lingui but I don't like the React focus, heavy dependencies (babel), and lack of support for Svelte. I also wanted a solution that would only load the language the user has selected, and be possible to modularize and split up the locale translations based on directories they are used in to reduce the initial load.

This uses a Regex to determine which GetText strings are used in the JavaScript and generates a JS file for each locale. It stores the translations in a global window.translations variable which overrides the local GetText translations. The GetText functions are bare-bones but do the job for me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment