Skip to content

Instantly share code, notes, and snippets.

@angelikatyborska
Last active October 9, 2024 21:10
Show Gist options
  • Save angelikatyborska/cebc3de03c08307edebf6054ed09ff5f to your computer and use it in GitHub Desktop.
Save angelikatyborska/cebc3de03c08307edebf6054ed09ff5f to your computer and use it in GitHub Desktop.
A custom `gettext_with_link` macro for easily putting inline links into gettext strings
# Has one external dependency except for Gettext: https://github.com/rrrene/html_sanitize_ex
defmodule MyApp.Gettext do
@doc """
A helper for translations with links.
Pass in the translation string which must include
`%{link_start}`/`%{link_end}`. For multiple URLs, use
`%{link_start_<0,1,2...>}`.
Pass in either a single `url_string` or tuple `{url_string,
html_attributes_string}`, or a list thereof, under the key `:link` or
`:links`, in the same order as the link start markers (i.e. the first item in
the list will be injected as `link_start_0` etc.) in the translation string.
## Usage examples:
### Single links:
iex> import MyApp.Gettext
iex> gettext_with_link("This will be %{link_start}linked to Google%{link_end}", link: {"https://google.com", ~s(target="_blank")})
{:safe, ~s|This will be <a href="https://google.com" target="_blank">linked to Google</a>|}
### Multiple links:
iex> gettext_with_link("This will be %{link_start_0}linked to Google (new tab)%{link_end_0} and %{link_start_1}this to Yahoo%{link_end_1}",
...> links: [{"https://google.com", ~s(target="_blank" class="foobar")}, "https://yahoo.com"]
...> )
{:safe, ~s|This will be <a href="https://google.com" target="_blank" class="foobar">linked to Google (new tab)</a> and <a href="https://yahoo.com">this to Yahoo</a>|}
### Links mixed with other variables:
iex> import MyApp.Gettext
iex> gettext_with_link("Read more of %{link_start}%{post_title}%{link_end}", post_title: "Hello World!", links: [{"https://example.com/myblog/post1", ~s(target="_blank")}])
{:safe, ~s|Read more of <a href="https://example.com/myblog/post1" target="_blank">Hello World!</a>|}
"""
defmacro gettext_with_link(string, opts) do
quote do
dgettext_with_link("default", unquote(string), unquote(opts))
end
end
@doc """
Same as gettext_with_link/2 but allows for choosing a domain.
"""
defmacro dgettext_with_link(domain, string, opts) do
links =
Keyword.get(opts, :links) || Keyword.get(opts, :link) ||
raise "You must pass one or more links as `:link` or `:links`"
links = List.wrap(links)
opts = Keyword.drop(opts, [:links, :link])
if length(links) > 1 do
{_, links} =
Enum.reduce(links, {0, []}, fn link_definition, {i, links} ->
{url, link_attrs} =
case link_definition do
{url, link_attrs} -> {url, link_attrs}
url -> {url, nil}
end
links =
quote do
unquote(links)
|> Keyword.put(
String.to_atom("link_start_#{unquote(i)}"),
"<a href=\"#{unquote(url)}\" #{unquote(link_attrs)}>"
)
|> Keyword.put(String.to_atom("link_end_#{unquote(i)}"), "</a>")
end
{i + 1, links}
end)
quote do
string =
dgettext(unquote(domain), unquote(string), Keyword.merge(unquote(links), unquote(opts)))
MyApp.RawHelpers.sanitized_raw(:inline, string)
end
else
link = List.first(links)
{url, link_attrs} =
case link do
{url, link_attrs} -> {url, link_attrs}
url -> {url, nil}
end
links =
quote do
[
link_start: "<a href=\"#{unquote(url)}\" #{unquote(link_attrs)}>",
link_end: "</a>"
]
end
quote do
string =
dgettext(unquote(domain), unquote(string), Keyword.merge(unquote(links), unquote(opts)))
MyApp.RawHelpers.sanitized_raw(:inline, string)
end
end
end
end
defmodule MyApp.RawHelpers do
@doc """
This function is meant as a compromise for when you want to use Phoenix.HTML.raw.
It will print the string raw, but first it will sanitize it with the chosen scrubber module.
It is NOT a replacement for sanitizing user input, but an additional safeguard.
- `:inline` - leaves safe elements that are allowed where inline elements are allowed (e.g. links, buttons, em).
Meant to be used with gettext strings that need to use <em>, <strong> etc.
- `:block` - similar to `:inline`, but also allows some block elements (e.g. h1-6, p, blockquote)
Meant to be used in emails where `raw` is necessary but `:inline` is not enough
"""
def sanitized_raw(:inline, string), do: do_sanitized_raw(string, MyApp.HTMLScrubber.Inline)
def sanitized_raw(:block, string), do: do_sanitized_raw(string, MyApp.HTMLScrubber.Block)
# sobelow_skip ["XSS.Raw"]
defp do_sanitized_raw(string, scrubber_module) do
string
|> HtmlSanitizeEx.Scrubber.scrub(scrubber_module)
|> Phoenix.HTML.raw()
end
end
defmodule MyApp.HTMLScrubber.Inline do
@moduledoc """
HTMLScrubber for the content of inline elements, for example gettext strings that include em/strong/links.
"""
require HtmlSanitizeEx.Scrubber.Meta
alias HtmlSanitizeEx.Scrubber.Meta
@valid_schemes ["http", "https", "mailto"]
Meta.remove_cdata_sections_before_scrub()
Meta.strip_comments()
for tag <- ~w(span strong em b i br) do
Meta.allow_tag_with_these_attributes(unquote(tag), [])
end
Meta.allow_tag_with_these_attributes("button", [
"id",
"class",
"style",
"aria-label",
"aria-hidden"
])
Meta.allow_tag_with_these_attributes(
"a",
[
"id",
"title",
"target",
"class",
"style",
"aria-label",
"aria-hidden"
]
)
Meta.allow_tag_with_uri_attributes("a", ["href"], @valid_schemes)
Meta.strip_everything_not_covered()
end
@saveman71
Copy link

saveman71 commented Nov 14, 2023

Hi Angelika!

Very nice of you to provide this sample implementation. I hope I'm not intrusive by appending my own rewrite that solves some code repetition issues and seems more readable to me:

defmodule MyApp.Gettext do
  use Gettext, otp_app: :my_app

  @doc """
  A helper for translations with links.

  Pass in the translation string which must include
  `%{link_start}`/`%{link_end}`. For multiple URLs, use
  `%{link_start_<0,1,2...>}`.

  Pass in either a single `url_string` or tuple `{url_string,
  html_attributes_string}`, or a list thereof, under the key `:link` or
  `:links`, in the same order as the link start markers (i.e. the first item in
  the list will be injected as `link_start_0` etc.) in the translation string.

  ## Usage examples:

  ### Single links:

      iex> gettext_with_link("This will be %{link_start}linked to Google%{link_end}", link: {"https://google.com", ~s(target="_blank")})
      {:safe, ~s|This will be <a href="https://google.com" target="_blank">linked to Google</a>|}

  ### Multiple links:

      iex> gettext_with_link("This will be %{link_start_0}linked to Google (new tab)%{link_end_0} and %{link_start_1}this to Yahoo%{link_end_1}",
      ...>                links: [{"https://google.com", ~s(target="_blank" class="foobar")}, "https://yahoo.com"]
      ...>             )
      {:safe, ~s|This will be <a href="https://google.com" target="_blank" class="foobar">linked to Google (new tab)</a> and <a href="https://yahoo.com">this to Yahoo</a>|}

  ### Links mixed with other variables:

      iex> gettext_with_link("Read more of %{link_start}%{post_title}%{link_end}", post_title: "Hello World!", links: [{"https://example.com/myblog/post1", ~s(target="_blank")}])
      {:safe, ~s|Read more of <a href="https://example.com/myblog/post1" target="_blank">Hello World!</a>|}

  ### Dangerous tags are stripped:

      iex> gettext_with_link("Read more of %{link_start}<script>alert(1);</script>%{link_end}<h1>Hello world</h1>", links: [{"https://example.com/myblog/post1", ~s(target="_blank")}])
      {:safe, ~s|Read more of <a href="https://example.com/myblog/post1" target="_blank">alert(1);</a>Hello world|}

  """
  defmacro gettext_with_link(msgid, opts) do
    quote do
      dgettext_with_link("default", unquote(msgid), unquote(opts))
    end
  end

  @doc """
  Same as gettext_with_link/2 but allows for choosing a domain.
  """
  defmacro dgettext_with_link(domain, msgid, opts) do
    quote do
      dpgettext_with_link(unquote(domain), nil, unquote(msgid), unquote(opts))
    end
  end

  @doc """
  Same as gettext_with_link/2 but allows for choosing a context.
  """
  defmacro pgettext_with_link(msgctxt, msgid, opts) do
    quote do
      dpgettext_with_link("default", unquote(msgctxt), unquote(msgid), unquote(opts))
    end
  end

  defp put_bindings(bindings, link_definition, {link_start_key, link_end_key}) do
    {url, link_attrs} =
      case link_definition do
        {url, link_attrs} -> {url, link_attrs}
        url -> {url, nil}
      end

    quote do
      unquote(bindings)
      |> Keyword.put(unquote(link_start_key), "<a href=\"#{unquote(url)}\" #{unquote(link_attrs)}>")
      |> Keyword.put(unquote(link_end_key), "</a>")
    end
  end

  defp add_bindings(bindings, [link]) do
    put_bindings(bindings, link, {:link_start, :link_end})
  end

  defp add_bindings(bindings, links) do
    links
    |> Enum.with_index()
    |> Enum.reduce(bindings, fn {link_definition, i}, bindings ->
      put_bindings(bindings, link_definition, {:"link_start_#{i}", :"link_end_#{i}"})
    end)
  end

  @doc """
  Same as gettext_with_link/2 but allows for choosing a domain and context.
  """
  defmacro dpgettext_with_link(domain, msgctxt, msgid, bindings) do
    links =
      Keyword.get(bindings, :links) || Keyword.get(bindings, :link) ||
        raise "You must pass one or more links as `:link` or `:links`"

    links = List.wrap(links)

    bindings =
      bindings
      |> Keyword.drop([:links, :link])
      |> add_bindings(links)

    quote do
      string =
        dpgettext(unquote(domain), unquote(msgctxt), unquote(msgid), unquote(bindings))

      MyApp.RawHelpers.sanitized_raw(:very_basic, string)
    end
  end
end

It also handles contexts from pgettext.

If that's OK i might submit it (with credit of course) as a conclusion to my post on the elixir forums: https://elixirforum.com/t/how-to-create-an-i18n-able-link/55030 as this is the implementation we're going to use from now on.

cheers!

@angelikatyborska
Copy link
Author

Hi!

I hope I'm not intrusive by appending my own rewrite

Absolutely not, this is the strength of OSS 💪

If that's OK i might submit it (with credit of course) as a conclusion to my post on the elixir forums:

Go ahead, help others!

@rubas
Copy link

rubas commented Mar 11, 2024

@saveman71 @angelikatyborska

Here is a simplified version, which deals with patch and navigate from Liveview and uses a keyword list for the attributes.

It's easy extendable ...

PS: I start the counting at one (Enum.with_index(1)).

  @doc ~S"""
  A helper for translations with links.

  Pass in the translation string which must include  `%{link_start}`/`%{link_end}`. For multiple URLs, use `%{link_start_<0,1,2...>}`.

  Pass in either a single `url_string` or tuple `{url_string,  html_attributes_string}`, or a list thereof, under the key `:link` or
  `:links`, in the same order as the link start markers (i.e. the first item in the list will be injected as `link_start_0` etc.) in the translation string.

  ## Usage examples:

  ### Single links:

      iex> gettext_with_link("This will be %{link_start}linked to Google%{link_end}", link: [href: "https://google.com", target: "_blank"])
      {:safe, ~s|This will be <a href="https://google.com" target="_blank">linked to Google</a>|}

  ### Multiple links:

      iex> gettext_with_link("This will be %{link_start_1}linked to Google (new tab)%{link_end_1} and %{link_start_2}this to Yahoo%{link_end_2}",
      ...>                link: [[href: "https://google.com", target: "_blank", class: "foobar"], [href: "https://yahoo.com"]]
      ...>             )
      {:safe, ~s|This will be <a href="https://google.com" class="foobar" target="_blank">linked to Google (new tab)</a> and <a href="https://yahoo.com" >this to Yahoo</a>|}

  ### Links mixed with other variables:

      iex> gettext_with_link("Read more of %{link_start}%{post_title}%{link_end}", post_title: "Hello World!", link: [href: "https://example.com/myblog/post1", target: "_blank"])
      {:safe, ~s|Read more of <a href="https://example.com/myblog/post1" target="_blank">Hello World!</a>|}

  ### Liveview:

      iex> gettext_with_link("This will be %{link_start}LV Navigate Link%{link_end}", link: [navigate: "/navigate"])
      {:safe, ~s|This will be <a href="/navigate" data-phx-link="redirect" data-phx-link-state="push" >LV Navigate Link</a>|}

      iex> gettext_with_link("This will be %{link_start}LV Navigate Link%{link_end}", link: [navigate: "/navigate", replace: true])
      {:safe, ~s|This will be <a href="/navigate" data-phx-link="redirect" data-phx-link-state="replace" >LV Navigate Link</a>|}

      iex> gettext_with_link("This will be %{link_start}LV Patch Link%{link_end}", link: [patch: "/patch"])
      {:safe, ~s|This will be <a href="/patch" data-phx-link="patch" data-phx-link-state="push" >LV Patch Link</a>|}

      iex> gettext_with_link("This will be %{link_start}LV Patch Link%{link_end}", link: [patch: "/patch", replace: true])
      {:safe, ~s|This will be <a href="/patch" data-phx-link="patch" data-phx-link-state="replace" >LV Patch Link</a>|}

  """
  defmacro gettext_with_link(msgid, opts) do
    quote do
      dgettext_with_link("default", unquote(msgid), unquote(opts))
    end
  end

  @doc """
  Same as gettext_with_link/2 but allows for choosing a domain.
  """
  defmacro dgettext_with_link(domain, msgid, opts) do
    quote do
      dpgettext_with_link(unquote(domain), nil, unquote(msgid), unquote(opts))
    end
  end

  @doc """
  Same as gettext_with_link/2 but allows for choosing a context.
  """
  defmacro pgettext_with_link(msgctxt, msgid, opts) do
    quote do
      dpgettext_with_link("default", unquote(msgctxt), unquote(msgid), unquote(opts))
    end
  end

  @doc """
  Same as gettext_with_link/2 but allows for choosing a domain and context.
  """
  defmacro dpgettext_with_link(domain, msgctxt, msgid, bindings) do
    key = :link

    new_bindings =
      case Keyword.get(bindings, key) do
        nil ->
          []

        [list | _] = nested_list when is_list(list) ->
          nested_list
          |> Enum.with_index(1)
          |> Enum.reduce([], fn {opts, index}, acc -> Keyword.merge(acc, new_bindings(key, opts, index)) end)

        opts when is_list(opts) ->
          new_bindings(key, opts)
      end

    quote do
      bindings =
        unquote(bindings)
        |> Keyword.delete(unquote(key))
        |> Keyword.merge(unquote(new_bindings))

      Phoenix.HTML.raw(dpgettext(unquote(domain), unquote(msgctxt), unquote(msgid), bindings))
    end
  end

  # Tags _______________________________________________________________________________________________________________

  defp start_tag(opts) do
    attrs = attributes(opts)

    cond do
      url = Keyword.get(opts, :navigate) ->
        replace = Keyword.get(opts, :replace)

        quote do
          ~s(<a href="#{unquote(url)}" data-phx-link="redirect" data-phx-link-state="#{if unquote(replace), do: "replace", else: "push"}" #{unquote(attrs)}>)
        end

      url = Keyword.get(opts, :patch) ->
        replace = Keyword.get(opts, :replace)

        quote do
          ~s(<a href="#{unquote(url)}" data-phx-link="patch" data-phx-link-state="#{if unquote(replace), do: "replace", else: "push"}" #{unquote(attrs)}>)
        end

      url = Keyword.get(opts, :href) ->
        quote do
          ~s(<a href="#{unquote(url)}" #{unquote(attrs)}>)
        end

      true ->
        raise ArgumentError, "Missing option, add either `:navigate`, `:patch` or `:href` to `#{inspect(opts)}`"
    end
  end

  defp end_tag(_), do: "</a>"

  # Helper _____________________________________________________________________________________________________________

  defp new_bindings(key, opts, index \\ nil) when is_list(opts),
    do: [{start_key(key, index), start_tag(opts)}, {end_key(key, index), end_tag(opts)}]

  # credo:disable-for-next-line
  defp start_key(key, nil), do: :"#{key}_start"
  # credo:disable-for-next-line
  defp start_key(key, i) when is_integer(i), do: :"#{key}_start_#{i}"
  # credo:disable-for-next-line
  defp end_key(key, nil), do: :"#{key}_end"
  # credo:disable-for-next-line
  defp end_key(key, i) when is_integer(i), do: :"#{key}_end_#{i}"

  defp attributes(opts) when is_list(opts) do
    opts
    |> Keyword.take(~w(aria-label aria-hidden class download hreflang id referrerpolicy rel target type)a)
    |> Enum.reduce([], fn {key, value}, acc ->
      string =
        case key do
          :download ->
            if value, do: "download", else: nil

          _ ->
            ~s(#{key}="#{value}")
        end

      if string, do: [string] ++ acc, else: acc
    end)
    |> Enum.join(" ")
  end
end

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