Last active
October 9, 2024 21:10
-
-
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
This file contains 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
# 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 |
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!
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
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:
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!