Created
April 20, 2024 05:21
-
-
Save ryanb/cc41ea5b0ebcfc5cb236b1c7e03527c1 to your computer and use it in GitHub Desktop.
Simple GetText solution for JavaScript for use with translation.io
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<%# app/views/layouts/application.html.erb %> | |
... | |
<%= javascript_include_tag "locales/#{I18n.locale}", nonce: true %> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/assets/config/manifest.js | |
// ... | |
//= link_tree ../javascripts/locales |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {_} from "./translate"; | |
// ... | |
_("Hello %{name}!", {name: "Bob"}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | |
}); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | |
}); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 triedlingui
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.