Skip to content

Instantly share code, notes, and snippets.

@AlchemistCamp
Created July 2, 2018 08:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save AlchemistCamp/f97044e08a731941ab6833a6c284ab86 to your computer and use it in GitHub Desktop.
Save AlchemistCamp/f97044e08a731941ab6833a6c284ab86 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="<%= locale() %>">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta property="og:site_name" content="Portal">
<meta property="og:url" content="https://example.com">
<!-- Generate og:locale tags -->
<%= for {property, content} <- fb_locales() do %>
<%= Phoenix.HTML.Tag.tag(:meta, property: property, content: content) %>
<% end %>
<!-- Generate link alternate tags -->
<%= for {lang, path} <- language_annotations(@conn) do %>
<%= Phoenix.HTML.Tag.tag(:link, rel: "alternate", hreflang: lang, href: path) %>
<% end %>
...
defmodule Blank do
@moduledoc """
Tools around checking and handling undefined or blank data.
"""
@doc """
Returns `true` if data is considered blank/empty.
"""
def blank?(data) do
Blank.Protocol.blank?(data)
end
@doc """
Returns `true` if data is not considered blank/empty.
"""
def present?(data) do
!blank?(data)
end
@doc """
Returns the provided `data` if valid of the `default` value if not.
"""
def default_to(data, default) do
if blank?(data), do: default, else: data
end
end
defprotocol Blank.Protocol do
@moduledoc """
Provides only one single method definition `blank?/1`
"""
@doc """
Returns `true` if data is considered blank/empty.
"""
def blank?(data)
end
# Integers are never blank
defimpl Blank.Protocol, for: Integer do
def blank?(_), do: false
end
defimpl Blank.Protocol, for: BitString do
def blank?(""), do: true
def blank?(_), do: false
end
# Just empty list is blank
defimpl Blank.Protocol, for: List do
def blank?([]), do: true
def blank?(_), do: false
end
defimpl Blank.Protocol, for: Map do
# Keep in mind we could not pattern match on %{} because
# it matches on all maps. We can however check if the size
# is zero (and size is a fast operation).
def blank?(map), do: map_size(map) == 0
end
# Just the atoms false and nil are blank
defimpl Blank.Protocol, for: Atom do
def blank?(false), do: true
def blank?(nil), do: true
def blank?(_), do: false
end
use Mix.Config
# General application configuration
config :portal,
ecto_repos: [Portal.Repo],
env: "#{Mix.env()}"
config :portal, Portal.Gettext, locales: ~w(en zh)
# ...
# the locale must now be passed into path helpers
user_path(conn, :show, conn.assigns.locale, user.id)
defmodule Portal.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:
import Portal.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.
"""
use Gettext, otp_app: :portal
def supported_locales do
known = Gettext.known_locales(Portal.Gettext)
allowed = config()[:locales]
MapSet.intersection(MapSet.new(known), MapSet.new(allowed))
|> MapSet.to_list
end
defp config, do: Application.get_env(:portal, __MODULE__)
end
defmodule Portal.LayoutView do
use Portal.Web, :view
@doc """
Renders current locale.
"""
def locale do
Gettext.get_locale(Portal.Gettext)
end
@doc """
Provides tuples for all alternative languages supported.
"""
def fb_locales do
Portal.Gettext.supported_locales
|> Enum.map(fn l ->
# Cannot call `locale/0` inside guard clause
current = locale()
case l do
l when l == current -> {"og:locale", l}
l -> {"og:locale:alternate", l}
end
end)
end
@doc """
Provides tuples for all alternative languages supported.
"""
def language_annotations(conn) do
Portal.Gettext.supported_locales
|> Enum.reject(fn l -> l == locale() end)
|> Enum.concat(["x-default"])
|> Enum.map(fn l ->
case l do
"x-default" -> {"x-default", localized_url(conn, "")}
l -> {l, localized_url(conn, "/#{l}")}
end
end)
end
defp localized_url(conn, alt) do
# Replace current locale with alternative
path = ~r/\/#{locale()}(\/(?:[^?]+)?|$)/
|> Regex.replace(conn.request_path, "#{alt}\\1")
Phoenix.Router.Helpers.url(Portal.Router, conn) <> path
end
end
defmodule Portal.Plug.Locale do
import Plug.Conn
def init(default), do: default
def call(conn, default) do
locale = conn.params["locale"]
path = conn.request_path
is_ietf_tag = BCP47.valid?(locale, ietf_only: true)
path_head =
String.split(path, "/")
|> Enum.filter(&(String.length(&1) > 0))
|> Enum.at(0)
cond do
is_ietf_tag && locale in Portal.Gettext.supported_locales() ->
# Check if locale param contains a valid Locale
IO.inspect("Supported locale (#{locale}).")
conn |> assign_locale!(locale)
path_head in Portal.Gettext.supported_locales() ->
conn |> assign_locale!(path_head)
true ->
IO.inspect("Unsupported locale (#{locale}).")
# Get locale based on user agent and redirect
locale = List.first(accept_langs(conn)) || default
# Invalid or missing locales redirect with user agent locale
path =
if is_ietf_tag do
localized_path(conn.request_path, locale, conn.params["locale"])
else
localized_path(conn.request_path, locale)
end
IO.puts("Redirecting from #{conn.request_path}\nto: #{path}")
conn |> redirect_to(path) |> halt()
end
end
defp accept_langs(conn) do
[accept_lang_str] = Plug.Conn.get_req_header(conn, "accept-language")
accept_lang_str
|> String.split(";")
|> List.first()
|> String.split(",")
|> Enum.map(&(String.split(&1, "-") |> List.first()))
|> Enum.filter(&Enum.member?(Portal.Gettext.supported_locales(), &1))
end
defp assign_locale!(conn, value) do
# Apply the locale as a process var and continue
Gettext.put_locale(Portal.Gettext, value)
conn
|> assign(:locale, value)
end
defp localized_path(request_path, locale, original) do
# If locale is an ietf tag, we don't support it. In this case,
# replace the tag with the new locale.
~r/(\/)#{original}(\/(?:.+)?|\?(?:.+)?|$)/
|> Regex.replace(request_path, "\\1#{locale}\\2")
end
defp localized_path(request_path, locale) do
# If locale is not an ietf tag, it is a page request.
"/#{locale}#{request_path}"
end
defp redirect_to(conn, path) do
# Apply query if present
path =
unless Blank.blank?(conn.query_string) do
path <> "?#{conn.query_string}"
else
path
end
# Redirect
conn |> Phoenix.Controller.redirect(to: path)
end
end

Internationalizing a Phoenix App: Gettext, routing and meta tags

Goals

  • A consistent structure under a single domain
  • SEO friendliness
  • Avoid geo-location and other user-hostile practices
  • Do as much as possible from the back-end

Inspiration

  • Painful geo-location experiences (especially in a place with an unfamiliar script)
  • Mixed experiences with even the best of JS solutions, such as localize.js

High level strategy

We'll set the site language based on the first item in this list that is a supported language:

  • The locale in the URL
  • The accept-language in the request header
  • The global default language for the site

Based on the site language and the supporting languages we'll

  • Set the og:locale and the og:locale:alternate in our header
  • Add rel="alternate" tags to the header
  • Place the locale into both the URL and Conn assigns
  • Use Gettext to retrieve localized strings to display

Links

defmodule Portal.Router do
use Portal.Web, :router
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_flash)
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
plug(Portal.Auth, repo: Portal.Repo)
plug(Portal.Plug.Locale, "en")
end
pipeline :api do
plug(:fetch_session)
plug(Portal.Auth, repo: Portal.Repo)
plug(:accepts, ["json"])
end
scope "/api", Portal do
pipe_through([:api])
resources("/feedback", FeedbackController, except: [:new, :edit])
end
scope "/", Portal do
# Use the default browser stack
pipe_through([:browser])
# The dummy route is never hit but the router must have a "/" path.
get("/", PageController, :dummy)
end
scope "/:locale", Portal do
pipe_through([:browser])
resources "/users/", UserController do
resources("/relations", RelationController, only: [:index, :create, :update])
post("/followers/:id", RelationController, :followers_update)
put("/followers/:id", RelationController, :followers_update)
get("/followers", RelationController, :followers_index)
get("/following", RelationController, :following_index)
get("/friends", RelationController, :friends_index)
end
get("/experts/img/:id", InterviewController, :img)
get("/video/:id", InterviewController, :webrtc)
resources("/experts", InterviewController)
resources("/languages", LanguageController)
resources("/guests", GuestController)
# content creation for phonics quizzes
get("/phonics-items/spellings", PhoneticSpellingController, :global_index)
resources "/phonics-items", PhonicsItemController do
resources("/spellings", PhoneticSpellingController, name: "spelling")
end
post("/phonics-lessons/add_point", PhonicsLessonController, :add_point)
delete("/phonics-lessons/:id/remove_point/:point_id", PhonicsLessonController, :remove_point)
put("/phonics-lessons/add_quiz/:id", PhonicsLessonController, :add_quiz)
patch("/phonics-lessons/add_quiz/:id", PhonicsLessonController, :add_quiz)
delete("/phonics-lessons/remove_quiz/:id", PhonicsLessonController, :remove_quiz)
resources("/phonics-lessons", PhonicsLessonController)
post("/phonics-points/add_example", PhonicsPointController, :add_example)
delete("/phonics-points/remove_example/:id", PhonicsPointController, :remove_example)
resources("/phonics-points", PhonicsPointController)
post("/phonics-quizzes/add_item", PhonicsQuizController, :add_item)
delete("/phonics-quizzes/remove_item/:id", PhonicsQuizController, :remove_item)
resources("/phonics-quizzes", PhonicsQuizController)
# user-facing phonics routes
get("/phonics/quiz/:id", PhonicsQuizController, :take)
resources("/resources", ResourcePageController)
resources("/feedback", ResolutionController, only: [:index, :show, :edit, :update])
resources("/rooms", RoomController)
get("/room/chat/:room", RoomController, :chat)
resources("/sessions", SessionController, only: [:new, :create, :delete])
get("/signup", UserController, :new)
get("/login", SessionController, :new)
get("/logout", SessionController, :delete)
get("/", PageController, :index)
get("/:name", PageController, :show)
# the mighty Pokemon route
get("/*name", PageController, :show)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment