diff --git a/lib/prism/i18n/backend.rb b/lib/prism/i18n/backend.rb new file mode 100644 index 0000000..896e30d --- /dev/null +++ b/lib/prism/i18n/backend.rb @@ -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 diff --git a/lib/prism/i18n/translator.rb b/lib/prism/i18n/translator.rb new file mode 100644 index 0000000..9bb2535 --- /dev/null +++ b/lib/prism/i18n/translator.rb @@ -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