diff --git a/config/config.exs b/config/config.exs index 01cd4e484..6145a5c89 100644 --- a/config/config.exs +++ b/config/config.exs @@ -181,26 +181,8 @@ config :http_signatures, config :mobilizon, :cldr, locales: [ - "ar", - "be", - "ca", - "cs", - "de", - "en", - "es", - "fi", "fr", - "gl", - "hu", - "it", - "ja", - "nl", - "nn", - "oc", - "pl", - "pt", - "ru", - "sv" + "en" ] config :mobilizon, :activitypub, diff --git a/config/dev.exs b/config/dev.exs index 265761685..a58b0c969 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -92,13 +92,6 @@ config :mobilizon, :instance, # config :mobilizon, :activitypub, sign_object_fetches: false -# No need to compile every locale in development environment -config :mobilizon, :cldr, - locales: [ - "fr", - "en" - ] - config :mobilizon, :anonymous, reports: [ allowed: true diff --git a/config/prod.exs b/config/prod.exs index 5dd0e3b32..5107dc6bd 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -13,6 +13,31 @@ config :mobilizon, Mobilizon.Web.Endpoint, # Do not print debug messages in production config :logger, level: :info +# Load all locales in production +config :mobilizon, :cldr, + locales: [ + "ar", + "be", + "ca", + "cs", + "de", + "en", + "es", + "fi", + "fr", + "gl", + "hu", + "it", + "ja", + "nl", + "nn", + "oc", + "pl", + "pt", + "ru", + "sv" + ] + cond do System.get_env("INSTANCE_CONFIG") && File.exists?("./config/#{System.get_env("INSTANCE_CONFIG")}") -> diff --git a/lib/mobilizon/cldr.ex b/lib/mobilizon/cldr.ex index 0188b8708..3759211f7 100644 --- a/lib/mobilizon/cldr.ex +++ b/lib/mobilizon/cldr.ex @@ -5,6 +5,10 @@ defmodule Mobilizon.Cldr do use Cldr, locales: Application.get_env(:mobilizon, :cldr)[:locales], - gettext: Mobilizon.Web.Gettext, + gettext: + if(Application.fetch_env!(:mobilizon, :env) == :prod, + do: Mobilizon.Web.Gettext, + else: nil + ), providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime, Cldr.Language] end diff --git a/lib/service/formatter/html.ex b/lib/service/formatter/html.ex index 926d3fee2..ef4cdd6a4 100644 --- a/lib/service/formatter/html.ex +++ b/lib/service/formatter/html.ex @@ -32,7 +32,7 @@ defmodule Mobilizon.Service.Formatter.HTML do @spec strip_tags_and_insert_spaces(String.t()) :: String.t() def strip_tags_and_insert_spaces(html) when is_binary(html) do html - |> String.replace("</", " </") + |> String.replace("><", "> <") |> strip_tags() end diff --git a/lib/service/metadata/actor.ex b/lib/service/metadata/actor.ex index 5677edf8e..fd3988ddb 100644 --- a/lib/service/metadata/actor.ex +++ b/lib/service/metadata/actor.ex @@ -4,20 +4,32 @@ defimpl Mobilizon.Service.Metadata, for: Mobilizon.Actors.Actor do alias Mobilizon.Actors.Actor alias Mobilizon.Web.JsonLD.ObjectView alias Mobilizon.Web.MediaProxy + import Mobilizon.Service.Metadata.Utils, only: [process_description: 2, default_description: 1] + + def build_tags(_actor, _locale \\ "en") + + def build_tags(%Actor{type: :Group} = group, locale) do + default_desc = default_description(locale) + + group = + Map.update(group, :summary, default_desc, fn summary -> + process_description(summary, locale) + end) - def build_tags(%Actor{} = actor, _locale \\ "en") do [ - Tag.tag(:meta, property: "og:title", content: Actor.display_name_and_username(actor)), - Tag.tag(:meta, property: "og:url", content: actor.url), - Tag.tag(:meta, property: "og:description", content: actor.summary), + Tag.tag(:meta, property: "og:title", content: Actor.display_name_and_username(group)), + Tag.tag(:meta, property: "og:url", content: group.url), + Tag.tag(:meta, property: "og:description", content: group.summary), Tag.tag(:meta, property: "og:type", content: "profile"), - Tag.tag(:meta, property: "profile:username", content: actor.preferred_username), + Tag.tag(:meta, property: "profile:username", content: group.preferred_username), Tag.tag(:meta, property: "twitter:card", content: "summary") ] - |> maybe_add_avatar(actor) - |> maybe_add_group_schema(actor) + |> maybe_add_avatar(group) + |> add_group_schema(group) end + def build_tags(%Actor{} = _actor, _locale), do: [] + @spec maybe_add_avatar(list(Tag.t()), Actor.t()) :: list(Tag.t()) defp maybe_add_avatar(tags, actor) do if is_nil(actor.avatar) do @@ -28,12 +40,10 @@ defimpl Mobilizon.Service.Metadata, for: Mobilizon.Actors.Actor do end end - defp maybe_add_group_schema(tags, %Actor{type: :Group} = group) do + defp add_group_schema(tags, %Actor{} = group) do tags ++ [~s{<script type="application/ld+json">#{json(group)}</script>} |> HTML.raw()] end - defp maybe_add_group_schema(tags, _), do: tags - # Insert JSON-LD schema by hand because Tag.content_tag wants to escape it defp json(%Actor{} = group) do "group.json" diff --git a/lib/service/metadata/instance.ex b/lib/service/metadata/instance.ex index aef15f862..5c88e4478 100644 --- a/lib/service/metadata/instance.ex +++ b/lib/service/metadata/instance.ex @@ -7,11 +7,15 @@ defmodule Mobilizon.Service.Metadata.Instance do alias Phoenix.HTML.Tag alias Mobilizon.Config - alias Mobilizon.Service.Formatter.HTML, as: HTMLFormatter + alias Mobilizon.Service.Metadata.Utils alias Mobilizon.Web.Endpoint + @doc """ + Build the list of tags for the instance + """ + @spec build_tags() :: list(Phoenix.HTML.safe()) def build_tags do - description = process_description(Config.instance_description()) + description = Utils.process_description(Config.instance_description()) title = "#{Config.instance_name()} - Mobilizon" instance_json_ld = """ @@ -38,11 +42,4 @@ defmodule Mobilizon.Service.Metadata.Instance do HTML.raw(instance_json_ld) ] end - - defp process_description(description) do - description - |> HTMLFormatter.strip_tags() - |> String.slice(0..200) - |> (&"#{&1}…").() - end end diff --git a/lib/service/metadata/metadata.ex b/lib/service/metadata/metadata.ex index eb58edb70..01def810f 100644 --- a/lib/service/metadata/metadata.ex +++ b/lib/service/metadata/metadata.ex @@ -1,7 +1,13 @@ defprotocol Mobilizon.Service.Metadata do - @doc """ - Build tags + @moduledoc """ + Service that allows producing metadata HTML tags about content """ + @doc """ + Build tags for an entity. Returns a list of `t:Phoenix.HTML.safe/0` tags. + + Locale can be provided to generate fallback descriptions. + """ + @spec build_tags(any(), String.t()) :: list(Phoenix.HTML.safe()) def build_tags(entity, locale \\ "en") end diff --git a/lib/service/metadata/utils.ex b/lib/service/metadata/utils.ex index f2c25bdf4..1fddcba1f 100644 --- a/lib/service/metadata/utils.ex +++ b/lib/service/metadata/utils.ex @@ -9,28 +9,63 @@ defmodule Mobilizon.Service.Metadata.Utils do @slice_limit 200 - @spec stringify_tags(Enum.t()) :: String.t() + @doc """ + Converts list of tags, containing either `t:Phoenix.HTML.safe/0` or strings, to a concatenated string listing the tags + """ + @spec stringify_tags(list(Phoenix.HTML.safe() | String.t())) :: String.t() def stringify_tags(tags), do: Enum.reduce(tags, "", &stringify_tag/2) - defp stringify_tag(tag, acc) when is_tuple(tag), do: acc <> HTML.safe_to_string(tag) - defp stringify_tag(tag, acc) when is_binary(tag), do: acc <> tag - + @doc """ + Removes the HTML tags from a text + """ @spec strip_tags(String.t()) :: String.t() - def strip_tags(text), do: HTMLFormatter.strip_tags(text) + def strip_tags(text), do: HTMLFormatter.strip_tags_and_insert_spaces(text) + @doc """ + Processes a text and limits it. + + * Removes the HTML tags from a text + * Slices it to a limit and add an ellipsis character + * Returns a default description if text is empty + """ @spec process_description(String.t(), String.t(), integer()) :: String.t() def process_description(description, locale \\ "en", limit \\ @slice_limit) def process_description(nil, locale, limit), do: process_description("", locale, limit) def process_description("", locale, _limit) do - Gettext.put_locale(locale) - gettext("The event organizer didn't add any description.") + default_description(locale) end def process_description(description, _locale, limit) do description - |> HTMLFormatter.strip_tags() - |> String.slice(0..limit) - |> (&"#{&1}…").() + |> HTMLFormatter.strip_tags_and_insert_spaces() + |> String.trim() + |> maybe_slice(limit) end + + @doc """ + Returns the default description for a text + """ + @spec default_description(String.t()) :: String.t() + def default_description(locale \\ "en") do + Gettext.put_locale(locale) + gettext("The event organizer didn't add any description.") + end + + defp maybe_slice(description, limit) do + if String.length(description) > limit do + description + |> String.slice(0..limit) + |> String.trim() + |> (&"#{&1}…").() + else + description + end + end + + @spec stringify_tag(Phoenix.HTML.safe(), String.t()) :: String.t() + defp stringify_tag(tag, acc) when is_tuple(tag), do: acc <> HTML.safe_to_string(tag) + + @spec stringify_tag(String.t(), String.t()) :: String.t() + defp stringify_tag(tag, acc) when is_binary(tag), do: acc <> tag end diff --git a/test/service/metadata/instance_test.exs b/test/service/metadata/instance_test.exs new file mode 100644 index 000000000..8e7033e80 --- /dev/null +++ b/test/service/metadata/instance_test.exs @@ -0,0 +1,24 @@ +defmodule Mobilizon.Service.Metadata.InstanceTest do + alias Mobilizon.Config + alias Mobilizon.Service.Metadata.{Instance, Utils} + alias Mobilizon.Web.Endpoint + use Mobilizon.DataCase + + describe "build_tags/0 for the instance" do + test "gives tags" do + title = "#{Config.instance_name()} - Mobilizon" + description = Utils.process_description(Config.instance_description()) + + assert Instance.build_tags() |> Utils.stringify_tags() == + "<title>#{title}</title><meta content=\"#{description}\" name=\"description\"><meta content=\"#{ + title + }\" property=\"og:title\"><meta content=\"#{Endpoint.url()}\" property=\"og:url\"><meta content=\"#{ + description + }\" property=\"og:description\"><meta content=\"website\" property=\"og:type\"><script type=\"application/ld+json\">{\n\"@context\": \"http://schema.org\",\n\"@type\": \"WebSite\",\n\"name\": \"#{ + title + }\",\n\"url\": \"#{Endpoint.url()}\",\n\"potentialAction\": {\n\"@type\": \"SearchAction\",\n\"target\": \"#{ + Endpoint.url() + }/search?term={search_term}\",\n\"query-input\": \"required name=search_term\"\n}\n}</script>\n" + end + end +end diff --git a/test/service/metadata/metadata_test.exs b/test/service/metadata/metadata_test.exs new file mode 100644 index 000000000..478d2f0a5 --- /dev/null +++ b/test/service/metadata/metadata_test.exs @@ -0,0 +1,143 @@ +defmodule Mobilizon.Service.MetadataTest do + alias Mobilizon.Actors.Actor + alias Mobilizon.Discussions.Comment + alias Mobilizon.Events.Event + alias Mobilizon.Posts.Post + alias Mobilizon.Service.Metadata + alias Mobilizon.Tombstone + use Mobilizon.DataCase + import Mobilizon.Factory + + describe "build_tags/2 for an actor" do + test "that is a group gives tags" do + %Actor{} = group = insert(:group, name: "My group") + + assert group |> Metadata.build_tags() |> Metadata.Utils.stringify_tags() == + "<meta content=\"#{group.name} (@#{group.preferred_username})\" property=\"og:title\"><meta content=\"#{ + group.url + }\" property=\"og:url\"><meta content=\"The event organizer didn't add any description.\" property=\"og:description\"><meta content=\"profile\" property=\"og:type\"><meta content=\"#{ + group.preferred_username + }\" property=\"profile:username\"><meta content=\"summary\" property=\"twitter:card\"><meta content=\"#{ + group.avatar.url + }\" property=\"og:image\"><script type=\"application/ld+json\">{\"@context\":\"http://schema.org\",\"@type\":\"Organization\",\"address\":null,\"name\":\"#{ + group.name + }\",\"url\":\"#{group.url}\"}</script>" + + assert group + |> Map.put(:avatar, nil) + |> Metadata.build_tags() + |> Metadata.Utils.stringify_tags() == + "<meta content=\"#{group.name} (@#{group.preferred_username})\" property=\"og:title\"><meta content=\"#{ + group.url + }\" property=\"og:url\"><meta content=\"The event organizer didn't add any description.\" property=\"og:description\"><meta content=\"profile\" property=\"og:type\"><meta content=\"#{ + group.preferred_username + }\" property=\"profile:username\"><meta content=\"summary\" property=\"twitter:card\"><script type=\"application/ld+json\">{\"@context\":\"http://schema.org\",\"@type\":\"Organization\",\"address\":null,\"name\":\"#{ + group.name + }\",\"url\":\"#{group.url}\"}</script>" + end + + test "that is not a group doesn't give anything" do + %Actor{} = person = insert(:actor) + + assert person |> Metadata.build_tags() |> Metadata.Utils.stringify_tags() == "" + assert person |> Metadata.build_tags("fr") |> Metadata.Utils.stringify_tags() == "" + end + end + + describe "build_tags/2 for an event" do + test "gives tags" do + alias Mobilizon.Web.Endpoint + + %Event{} = event = insert(:event) + + # Because the description in Schema.org data is double-escaped + a = "\n" + b = "\\n" + + assert event + |> Metadata.build_tags() + |> Metadata.Utils.stringify_tags() == + "<title>#{event.title} - Mobilizon</title><meta content=\"#{event.description}\" name=\"description\"><meta content=\"#{ + event.title + }\" property=\"og:title\"><meta content=\"#{event.url}\" property=\"og:url\"><meta content=\"#{ + event.description + }\" property=\"og:description\"><meta content=\"website\" property=\"og:type\"><link href=\"#{ + event.url + }\" rel=\"canonical\"><meta content=\"#{event.picture.file.url}\" property=\"og:image\"><meta content=\"summary_large_image\" property=\"twitter:card\"><script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"Event\",\"description\":\"#{ + String.replace(event.description, a, b) + }\",\"endDate\":\"#{DateTime.to_iso8601(event.ends_on)}\",\"eventStatus\":\"https://schema.org/EventScheduled\",\"image\":[\"#{ + event.picture.file.url + }\"],\"location\":{\"@type\":\"Place\",\"address\":{\"@type\":\"PostalAddress\",\"addressCountry\":\"My Country\",\"addressLocality\":\"My Locality\",\"addressRegion\":\"My Region\",\"postalCode\":\"My Postal Code\",\"streetAddress\":\"My Street Address\"},\"name\":\"#{ + event.physical_address.description + }\"},\"name\":\"#{event.title}\",\"organizer\":{\"@type\":\"Person\",\"name\":\"#{ + event.organizer_actor.preferred_username + }\"},\"performer\":{\"@type\":\"Person\",\"name\":\"#{ + event.organizer_actor.preferred_username + }\"},\"startDate\":\"#{DateTime.to_iso8601(event.begins_on)}\"}</script>" + + assert event + |> Map.put(:picture, nil) + |> Metadata.build_tags() + |> Metadata.Utils.stringify_tags() == + "<title>#{event.title} - Mobilizon</title><meta content=\"#{event.description}\" name=\"description\"><meta content=\"#{ + event.title + }\" property=\"og:title\"><meta content=\"#{event.url}\" property=\"og:url\"><meta content=\"#{ + event.description + }\" property=\"og:description\"><meta content=\"website\" property=\"og:type\"><link href=\"#{ + event.url + }\" rel=\"canonical\"><meta content=\"summary_large_image\" property=\"twitter:card\"><script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"Event\",\"description\":\"#{ + String.replace(event.description, a, b) + }\",\"endDate\":\"#{DateTime.to_iso8601(event.ends_on)}\",\"eventStatus\":\"https://schema.org/EventScheduled\",\"image\":[\"#{ + "#{Endpoint.url()}/img/mobilizon_default_card.png" + }\"],\"location\":{\"@type\":\"Place\",\"address\":{\"@type\":\"PostalAddress\",\"addressCountry\":\"My Country\",\"addressLocality\":\"My Locality\",\"addressRegion\":\"My Region\",\"postalCode\":\"My Postal Code\",\"streetAddress\":\"My Street Address\"},\"name\":\"#{ + event.physical_address.description + }\"},\"name\":\"#{event.title}\",\"organizer\":{\"@type\":\"Person\",\"name\":\"#{ + event.organizer_actor.preferred_username + }\"},\"performer\":{\"@type\":\"Person\",\"name\":\"#{ + event.organizer_actor.preferred_username + }\"},\"startDate\":\"#{DateTime.to_iso8601(event.begins_on)}\"}</script>" + end + end + + describe "build_tags/2 for a post" do + test "gives tags" do + %Post{} = post = insert(:post) + + assert post + |> Metadata.build_tags() + |> Metadata.Utils.stringify_tags() == + "<meta content=\"#{post.title}\" property=\"og:title\"><meta content=\"#{post.url}\" property=\"og:url\"><meta content=\"#{ + Metadata.Utils.process_description(post.body) + }\" property=\"og:description\"><meta content=\"article\" property=\"og:type\"><meta content=\"summary\" property=\"twitter:card\"><link href=\"#{ + post.url + }\" rel=\"canonical\"><meta content=\"summary_large_image\" property=\"twitter:card\"><script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Organization\",\"name\":\"#{ + post.attributed_to.preferred_username + }\"},\"dateModified\":\"#{DateTime.to_iso8601(post.updated_at)}\",\"datePublished\":\"#{ + DateTime.to_iso8601(post.publish_at) + }\",\"name\":\"My Awesome article\"}</script>" + end + end + + describe "build_tags/2 for a comment" do + test "gives tags" do + %Comment{} = comment = insert(:comment) + + assert comment + |> Metadata.build_tags() + |> Metadata.Utils.stringify_tags() == + "<meta content=\"#{comment.actor.preferred_username}\" property=\"og:title\"><meta content=\"#{ + comment.url + }\" property=\"og:url\"><meta content=\"#{comment.text}\" property=\"og:description\"><meta content=\"website\" property=\"og:type\"><meta content=\"summary\" property=\"twitter:card\">" + end + end + + describe "build_tags/2 for a tombstone" do + test "gives nothing" do + %Tombstone{} = tombstone = insert(:tombstone) + + assert tombstone + |> Metadata.build_tags() + |> Metadata.Utils.stringify_tags() == "" + end + end +end diff --git a/test/service/metadata/utils_test.exs b/test/service/metadata/utils_test.exs new file mode 100644 index 000000000..9ba6742ae --- /dev/null +++ b/test/service/metadata/utils_test.exs @@ -0,0 +1,60 @@ +defmodule Mobilizon.Service.Metadata.UtilsTest do + alias Mobilizon.Service.Metadata.Utils + use Mobilizon.DataCase + + describe "process_description/3" do + test "process_description/3 strip tags" do + assert Utils.process_description("<p>This is my biography</p>") == "This is my biography" + end + + test "process_description/3 cuts after a limit" do + assert Utils.process_description("<p>This is my biography</p>", "fr", 10) == + "This is my…" + end + + test "process_description/3 cuts after the default limit" do + assert Utils.process_description( + "<h1>Biography</h1><p>It all started when someone wanted a <b>very long string</b> to be cut. However it's difficult to invent things to write when you've got nothing to say. Anyway, what's the deal here. We just need to reach 200 characters.", + "fr" + ) == + "Biography It all started when someone wanted a very long string to be cut. However it's difficult to invent things to write when you've got nothing to say. Anyway, what's the deal here. We…" + end + + test "process_description/3 returns default if no description is provided" do + assert Utils.process_description(nil) == + "The event organizer didn't add any description." + + assert Utils.process_description("", "en") == + "The event organizer didn't add any description." + end + end + + describe "default_description/1" do + test "returns default description with a correct locale" do + assert Utils.default_description("en") == "The event organizer didn't add any description." + end + + test "returns default description with no locale provided" do + assert Utils.default_description() == "The event organizer didn't add any description." + end + end + + describe "stringify_tags/1" do + test "converts tags to string" do + alias Phoenix.HTML.Tag + + tag_1 = Tag.tag(:meta, property: "og:url", content: "one") + tag_2 = "<meta content=\"two\" property=\"og:url\">" + tag_3 = Tag.tag(:meta, property: "og:url", content: "three") + + assert Utils.stringify_tags([tag_1, tag_2, tag_3]) == + "<meta content=\"one\" property=\"og:url\"><meta content=\"two\" property=\"og:url\"><meta content=\"three\" property=\"og:url\">" + end + end + + describe "strip_tags/1" do + test "removes tags from string" do + assert Utils.strip_tags("<h1>Hello</h1><p>How are you</p>") == "Hello How are you" + end + end +end