Packages

phoenix_kit

2.52.2
2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web gettext.ex
Raw

lib/phoenix_kit_web/gettext.ex

defmodule PhoenixKitWeb.Gettext do
  @moduledoc """
  A module providing Internationalization with a gettext-based API.

  By using [Gettext](https://hexdocs.pm/gettext),
  your module gains a set of macros for translations, for example:

      use Gettext, backend: PhoenixKitWeb.Gettext

      # Simple translation
      gettext("Here is the string to translate")

      # Plural translation
      ngettext("Here is the string to translate",
               "Here are the strings to translate",
               3)

      # Domain-based translation
      dgettext("errors", "Here is the error message to translate")

  See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.

  This backend does **not** `use Gettext.Backend`. That macro compiles each
  message into a function clause, and ~2.7k messages × 7 translated locales
  is superlinear in the Erlang compiler (a clean `mix compile --force` spent
  ~13s on this file even after `split_module_by: [:locale]`, tripping the
  ">10s" notice). Translations are parsed from `priv/gettext` at compile
  time into a nested map, embedded as a compressed binary, and looked up
  with `Map.get/2`. Callers still go through `Gettext.dgettext/3` and friends;
  only the storage changes.
  """

  @behaviour Gettext.Backend

  require Logger

  alias PhoenixKitWeb.Gettext.Compiler

  @otp_app :phoenix_kit

  # Host config wins over the defaults, exactly like `Gettext.Backend.__using__`.
  # Every attribute below is derived from the MERGED opts -- reading a default
  # directly would build the catalog from a configured `:priv` while reporting
  # the unconfigured one to `mix gettext.extract`.
  @opts [
          otp_app: @otp_app,
          priv: "priv/gettext",
          interpolation: Gettext.Interpolation.Default,
          default_domain: "default"
        ]
        |> Keyword.merge(Application.compile_env(@otp_app, __MODULE__, []))
        |> Keyword.put_new(
          :plural_forms,
          Application.compile_env(:gettext, :plural_forms, Gettext.Plural)
        )

  @priv Keyword.fetch!(@opts, :priv)
  @interpolation Keyword.fetch!(@opts, :interpolation)
  @default_domain Keyword.fetch!(@opts, :default_domain)
  @plural_mod Keyword.fetch!(@opts, :plural_forms)

  @snapshot Compiler.snapshot(@opts)
  @catalog_bin @snapshot.binary
  @catalog_hash :erlang.md5(@snapshot.binary)
  @known_locales @snapshot.known_locales
  @plural_infos @snapshot.plural_infos
  @po_hash @snapshot.hash

  for path <- @snapshot.po_paths do
    @external_resource path
  end

  @doc false
  def __mix_recompile__? do
    @po_hash != Compiler.hash(@opts)
  end

  @doc false
  def __gettext__(:priv), do: @priv
  def __gettext__(:otp_app), do: @otp_app
  def __gettext__(:known_locales), do: @known_locales
  def __gettext__(:default_domain), do: @default_domain
  def __gettext__(:interpolation), do: @interpolation

  def __gettext__(:default_locale) do
    Keyword.get(@opts, :default_locale) || Application.fetch_env!(:gettext, :default_locale)
  end

  if Gettext.Extractor.extracting?() do
    Gettext.ExtractorAgent.add_backend(__MODULE__)
  end

  @impl Gettext.Backend
  def lgettext(locale, domain, msgctxt \\ nil, msgid, bindings)

  def lgettext(locale, domain, msgctxt, msgid, bindings) do
    case lookup(locale, domain, msgctxt, msgid) do
      {:singular, interpolatable} ->
        @interpolation.runtime_interpolate(interpolatable, bindings)

      {:plural, _msgid_plural, %{0 => interpolatable}, _source} ->
        @interpolation.runtime_interpolate(interpolatable, bindings)

      _ ->
        handle_missing_translation(locale, domain, msgctxt, msgid, bindings)
    end
  end

  @impl Gettext.Backend
  def lngettext(locale, domain, msgctxt \\ nil, msgid, msgid_plural, n, bindings)

  def lngettext(locale, domain, msgctxt, msgid, msgid_plural, n, bindings) do
    case lookup(locale, domain, msgctxt, msgid) do
      {:plural, ^msgid_plural, forms, source} ->
        interpolate_plural(locale, domain, forms, n, bindings, source)

      _ ->
        handle_missing_plural_translation(
          locale,
          domain,
          msgctxt,
          msgid,
          msgid_plural,
          n,
          bindings
        )
    end
  end

  @impl Gettext.Backend
  def handle_missing_bindings(exception, incomplete) do
    _ = Logger.error(Exception.message(exception))
    incomplete
  end

  @impl Gettext.Backend
  def handle_missing_translation(_locale, domain, _msgctxt, msgid, bindings) do
    Gettext.Compiler.warn_if_domain_contains_slashes(domain)

    with {:ok, interpolated} <- @interpolation.runtime_interpolate(msgid, bindings),
         do: {:default, interpolated}
  end

  @impl Gettext.Backend
  def handle_missing_plural_translation(
        _locale,
        domain,
        _msgctxt,
        msgid,
        msgid_plural,
        n,
        bindings
      ) do
    Gettext.Compiler.warn_if_domain_contains_slashes(domain)
    string = if n == 1, do: msgid, else: msgid_plural
    bindings = Map.put(bindings, :count, n)

    with {:ok, interpolated} <- @interpolation.runtime_interpolate(string, bindings),
         do: {:default, interpolated}
  end

  defp lookup(locale, domain, msgctxt, msgid) do
    catalog()
    |> Map.get(locale, %{})
    |> Map.get(domain, %{})
    |> Map.get({msgctxt, msgid}, :miss)
  end

  defp interpolate_plural(locale, domain, forms, n, bindings, {file, line}) do
    # `plural_info` carries the file's own `Plural-Forms:` header when it has
    # one, so a translator-authored rule wins over Gettext's built-in table --
    # and `@plural_mod` honours `config :gettext, :plural_forms`.
    form = @plural_mod.plural(Map.get(@plural_infos, {locale, domain}, locale), n)
    bindings = Map.put(bindings, :count, n)

    case forms do
      %{^form => interpolatable} ->
        @interpolation.runtime_interpolate(interpolatable, bindings)

      %{} ->
        raise Gettext.PluralFormError,
          form: form,
          locale: locale,
          file: file,
          line: line
    end
  end

  @doc """
  Decodes the embedded catalogue into `:persistent_term` ahead of the first
  lookup.

  Called from `PhoenixKit.Application.start/2`. The decode costs ~20ms and
  `:persistent_term.put/2` scans every process, so leaving it to the first
  `gettext` call puts both on a random request instead of on boot.
  """
  @spec warm_catalog() :: :ok
  def warm_catalog do
    _ = catalog()
    :ok
  end

  # The term is stored WITH the hash of the binary it was decoded from. The
  # key outlives the module: a recompile after a `.po` edit (the code
  # reloader, IEx `recompile`, a hot upgrade) loads a new `@catalog_bin`, and
  # a term cached under a bare key kept answering with the old strings until
  # the VM restarted. A mismatch re-decodes and replaces the term — one global
  # GC, on a recompile only.
  defp catalog do
    key = {__MODULE__, :catalog}

    case :persistent_term.get(key, :"$miss") do
      {@catalog_hash, cat} ->
        cat

      _stale_or_missing ->
        cat = :erlang.binary_to_term(@catalog_bin)
        :persistent_term.put(key, {@catalog_hash, cat})
        cat
    end
  end
end