feat(i18n): add Prism and I18n

This commit is contained in:
dualfroz committed 2026-09-07 15:35:54 +02:00
1 parent f309fb35ed
commit de19b8fabf
2 files changed
+152

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
module Prism
module I18n
# Holds translations for every locale as a nested Hash, keyed by symbol
# at every level ("posts.title" -> { posts: { title: "..." } }).
# Loaded from plain JSON files (no YAML/psych dependency - this build
# of Ruby has neither) or stored programmatically.
class Backend
def initialize
@translations = {}
end
def store_translations(locale, data)
locale = locale.to_sym
@translations[locale] ||= {}
deep_merge!(@translations[locale], symbolize(data))
end
def load_file(path)
locale = File.basename(path, '.*').to_sym
data = JSON.parse(File.read(path))
store_translations(locale, data)
locale
end
def lookup(locale, key)
segments = key.to_s.split('.').map(&:to_sym)
root = @translations[locale.to_sym]
return nil unless root
segments.reduce(root) do |node, segment|
return nil unless node.is_a?(Hash)
node[segment]
end
end
def locales
@translations.keys
end
def clear!
@translations = {}
end
private
def symbolize(value)
case value
when Hash
value.each_with_object({}) { |(key, nested), acc| acc[key.to_sym] = symbolize(nested) }
when Array
value.map { |entry| symbolize(entry) }
else
value
end
end
def deep_merge!(target, source)
source.each do |key, value|
if value.is_a?(Hash) && target[key].is_a?(Hash)
deep_merge!(target[key], value)
else
target[key] = value
end
end
target
end
end
end
end
+84
View File
@@ -0,0 +1,84 @@
module Prism
module I18n
# Resolves a translate/localize call against a Backend: locale fallback
# chains, %{name} interpolation, and English-style count pluralization
# (:zero/:one/:other keys, falling back to :other).
class Translator
MissingTranslationError = Class.new(StandardError)
MissingInterpolationError = Class.new(StandardError)
DEFAULT_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
DEFAULT_DATE_FORMAT = '%Y-%m-%d'
def initialize(backend, default_locale: :en, fallbacks: {})
@backend = backend
@default_locale = default_locale.to_sym
@fallbacks = fallbacks.transform_keys(&:to_sym).transform_values { |chain| Array(chain).map(&:to_sym) }
end
def translate(key, locale: @default_locale, count: nil, default: nil, **interpolations)
entry = resolve(key, locale)
if entry.nil?
return interpolate(default, count: count, **interpolations) unless default.nil?
raise MissingTranslationError, "translation missing: #{locale}.#{key}"
end
entry = pluralize(entry, count) if entry.is_a?(Hash) && !count.nil?
interpolate(entry, count: count, **interpolations)
end
alias_method :t, :translate
def localize(value, locale: @default_locale, format: :default)
pattern = resolve("#{scope_for(value)}.formats.#{format}", locale) ||
default_format_for(value)
value.strftime(pattern)
end
alias_method :l, :localize
private
def resolve(key, locale)
chain_for(locale).each do |candidate|
value = @backend.lookup(candidate, key)
return value unless value.nil?
end
nil
end
def chain_for(locale)
locale = locale.to_sym
chain = [locale] + @fallbacks.fetch(locale, [])
chain << @default_locale unless chain.include?(@default_locale)
chain.uniq
end
def pluralize(rules, count)
key = plural_key(count, rules)
rules[key] || rules[:other] || rules.values.first
end
def plural_key(count, rules)
return :zero if count == 0 && rules.key?(:zero)
return :one if count == 1
:other
end
def interpolate(text, count: nil, **interpolations)
return text unless text.is_a?(String)
values = interpolations.dup
values[:count] = count unless count.nil?
text.gsub(/%\{(\w+)\}/) do
key = Regexp.last_match(1).to_sym
values.fetch(key) { raise MissingInterpolationError, "missing interpolation argument #{key.inspect} for #{text.inspect}" }.to_s
end
end
def scope_for(value)
value.is_a?(Time) ? 'time' : 'date'
end
def default_format_for(value)
value.is_a?(Time) ? DEFAULT_TIME_FORMAT : DEFAULT_DATE_FORMAT
end
end
end
end