diff options
| -rw-r--r-- | app/helpers/social_helper.rb | 53 | ||||
| -rw-r--r-- | app/views/layouts/_social_meta.html.erb | 7 | ||||
| -rw-r--r-- | test/controllers/content_controller_test.rb | 40 |
3 files changed, 96 insertions, 4 deletions
diff --git a/app/helpers/social_helper.rb b/app/helpers/social_helper.rb index d6932a2f..e43f5a5c 100644 --- a/app/helpers/social_helper.rb +++ b/app/helpers/social_helper.rb | |||
| @@ -92,11 +92,14 @@ module SocialHelper | |||
| 92 | @page&.published_at&.iso8601 | 92 | @page&.published_at&.iso8601 |
| 93 | end | 93 | end |
| 94 | 94 | ||
| 95 | # request.path rather than a routing helper: it is already the locale's | 95 | # The address of the version actually being served, not of the URL that |
| 96 | # own canonical form -- unprefixed for German, /en/ for English -- and | 96 | # was requested. So /de/updates/foo points at /updates/foo, and a page |
| 97 | # dropping the query string is what makes it canonical. | 97 | # with no English translation requested under /en/ points at the German |
| 98 | # URL rather than claiming to be an English page. The query string is | ||
| 99 | # dropped, which is what makes it canonical. | ||
| 98 | def og_canonical_url | 100 | def og_canonical_url |
| 99 | og_absolute_url(request.path) | 101 | locale = @page&.persisted? ? og_locale_key : I18n.locale |
| 102 | og_locale_url(locale) | ||
| 100 | end | 103 | end |
| 101 | 104 | ||
| 102 | def og_locale | 105 | def og_locale |
| @@ -104,6 +107,48 @@ module SocialHelper | |||
| 104 | OG_LOCALES[I18n.default_locale.to_sym]) | 107 | OG_LOCALES[I18n.default_locale.to_sym]) |
| 105 | end | 108 | end |
| 106 | 109 | ||
| 110 | # Locales that can appear in a URL. Cccms::LOCALES will replace this when | ||
| 111 | # the set_locale allowlist lands; :root is in available_locales but is not | ||
| 112 | # a language. | ||
| 113 | def og_url_locales | ||
| 114 | I18n.available_locales - [:root] | ||
| 115 | end | ||
| 116 | |||
| 117 | # The request path with any locale prefix stripped, so a locale-specific | ||
| 118 | # URL can be rebuilt from it. /en/updates/foo, /de/updates/foo and | ||
| 119 | # /updates/foo all reduce to /updates/foo. | ||
| 120 | def og_bare_path | ||
| 121 | pattern = og_url_locales.join("|") | ||
| 122 | request.path.sub(%r{\A/(?:#{pattern})(?=/|\z)}, "").presence || "/" | ||
| 123 | end | ||
| 124 | |||
| 125 | # Absolute URL for this page in one locale. The default locale is | ||
| 126 | # unprefixed, matching default_url_options, so /updates/foo is the | ||
| 127 | # canonical German address and /de/updates/foo is a duplicate of it. | ||
| 128 | def og_locale_url(locale) | ||
| 129 | locale = locale.to_sym | ||
| 130 | locale = I18n.default_locale unless og_url_locales.include?(locale) | ||
| 131 | |||
| 132 | path = og_bare_path | ||
| 133 | return og_absolute_url(path) if locale == I18n.default_locale | ||
| 134 | |||
| 135 | og_absolute_url(path == "/" ? "/#{locale}" : "/#{locale}#{path}") | ||
| 136 | end | ||
| 137 | |||
| 138 | # One entry per locale in which the page genuinely has a translation, | ||
| 139 | # including a self-reference, which Google requires. A locale with no | ||
| 140 | # translation is omitted: /en/ would render German content through the | ||
| 141 | # fallback chain, which is not an English version of the page. With only | ||
| 142 | # one translation there is nothing to declare. | ||
| 143 | def og_hreflang_alternates | ||
| 144 | return [] unless @page&.persisted? | ||
| 145 | |||
| 146 | locales = @page.translated_locales.map(&:to_sym) & og_url_locales | ||
| 147 | return [] if locales.size < 2 | ||
| 148 | |||
| 149 | locales.sort.map { |locale| [locale.to_s, og_locale_url(locale)] } | ||
| 150 | end | ||
| 151 | |||
| 107 | # Only locales in which this page genuinely has a translation, so a | 152 | # Only locales in which this page genuinely has a translation, so a |
| 108 | # crawler is not told about a variant that would fall back. | 153 | # crawler is not told about a variant that would fall back. |
| 109 | def og_locale_alternates | 154 | def og_locale_alternates |
diff --git a/app/views/layouts/_social_meta.html.erb b/app/views/layouts/_social_meta.html.erb index 9a86d26d..36547d26 100644 --- a/app/views/layouts/_social_meta.html.erb +++ b/app/views/layouts/_social_meta.html.erb | |||
| @@ -23,6 +23,13 @@ | |||
| 23 | <meta property="og:locale:alternate" content="<%= alternate %>"/> | 23 | <meta property="og:locale:alternate" content="<%= alternate %>"/> |
| 24 | <% end %> | 24 | <% end %> |
| 25 | 25 | ||
| 26 | <% if (hreflang_alternates = og_hreflang_alternates).any? %> | ||
| 27 | <% hreflang_alternates.each do |code, url| %> | ||
| 28 | <link rel="alternate" hreflang="<%= code %>" href="<%= url %>"/> | ||
| 29 | <% end %> | ||
| 30 | <link rel="alternate" hreflang="x-default" href="<%= og_locale_url(I18n.default_locale) %>"/> | ||
| 31 | <% end %> | ||
| 32 | |||
| 26 | <% if og_published_time %> | 33 | <% if og_published_time %> |
| 27 | <meta property="article:published_time" content="<%= og_published_time %>"/> | 34 | <meta property="article:published_time" content="<%= og_published_time %>"/> |
| 28 | <% end %> | 35 | <% end %> |
diff --git a/test/controllers/content_controller_test.rb b/test/controllers/content_controller_test.rb index 304fd921..5bb02f3d 100644 --- a/test/controllers/content_controller_test.rb +++ b/test/controllers/content_controller_test.rb | |||
| @@ -190,6 +190,46 @@ class ContentControllerTest < ActionController::TestCase | |||
| 190 | FileUtils.rm_rf(Rails.root.join("tmp", "test_uploads", asset.id.to_s)) | 190 | FileUtils.rm_rf(Rails.root.join("tmp", "test_uploads", asset.id.to_s)) |
| 191 | end | 191 | end |
| 192 | end | 192 | end |
| 193 | |||
| 194 | test "a translated page declares hreflang alternates and canonicalises per locale" do | ||
| 195 | node = create_node_under_root "og_hreflang_test" | ||
| 196 | draft = find_or_create_draft(node, @user1) | ||
| 197 | draft.title = "Zweisprachig" | ||
| 198 | draft.save | ||
| 199 | node.publish_draft! | ||
| 200 | node.reload | ||
| 201 | Globalize.with_locale(:en) { node.head.update!(:title => "Bilingual") } | ||
| 202 | |||
| 203 | get :render_page, params: { :locale => "de", :page_path => ["og_hreflang_test"] } | ||
| 204 | |||
| 205 | assert_response :success | ||
| 206 | assert_select "link[rel=alternate][hreflang=de][href=?]", | ||
| 207 | "http://test.host/og_hreflang_test" | ||
| 208 | assert_select "link[rel=alternate][hreflang=en][href=?]", | ||
| 209 | "http://test.host/en/og_hreflang_test" | ||
| 210 | assert_select "link[rel=alternate][hreflang='x-default'][href=?]", | ||
| 211 | "http://test.host/og_hreflang_test" | ||
| 212 | assert_select "link[rel=canonical][href=?]", | ||
| 213 | "http://test.host/og_hreflang_test" | ||
| 214 | end | ||
| 215 | |||
| 216 | test "an untranslated page declares no alternates and canonicalises to the default locale" do | ||
| 217 | node = create_node_under_root "og_single_locale_test" | ||
| 218 | draft = find_or_create_draft(node, @user1) | ||
| 219 | draft.title = "Nur Deutsch" | ||
| 220 | draft.save | ||
| 221 | node.publish_draft! | ||
| 222 | |||
| 223 | get :render_page, params: { :locale => "en", :page_path => ["og_single_locale_test"] } | ||
| 224 | |||
| 225 | assert_response :success | ||
| 226 | assert_select "link[rel=alternate][hreflang]", false, | ||
| 227 | "one translation is nothing to declare" | ||
| 228 | # Served German through the fallback chain, so the German URL is | ||
| 229 | # canonical rather than the /en/ address that was requested. | ||
| 230 | assert_select "link[rel=canonical][href=?]", | ||
| 231 | "http://test.host/og_single_locale_test" | ||
| 232 | end | ||
| 193 | 233 | ||
| 194 | protected | 234 | protected |
| 195 | 235 | ||
