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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@saveman71 @angelikatyborska
Here is a simplified version, which deals with
patch
andnavigate
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)
).