Problem/Motivation

When an entity has available translations, in the HTML head, list the links to all available languages using the 'rel="alternate" hreflang="x"' approach described in https://support.google.com/webmasters/answer/189077.

This is necessary for SEO. When content is translated into multiple languages, search engines look for link tags with hreflang attributes in the HTML head to determine available translations so that they may surface results appropriate to the searcher's location and language.

This is a follow-up to #1164682: links with a known language need language identifier. See that issue for more background, discussion, and earlier attempts to achieve this functionality. At the end only the l() function got this feature and no header changes were made there.

Proposed resolution

Add the links to HTML head.

Remaining tasks

Do it. Tests. Reviews.

User interface changes

None.

API changes

None.

Comments

gábor hojtsy’s picture

Category: Feature request » Task
Issue summary: View changes
Issue tags: +D8MI, +language-base
Related issues: +#1164682: links with a known language need language identifier

Tagged, moved to a task and updated issue summary with template

paulmckibben’s picture

I am running into a stumbling block with this.

In the 8.0.x branch, I created my own branch, and I am attempting to implement hook_page_alter() in content_translation.module. Here's some code:

  $route_match = \Drupal::routeMatch();
  
  // Determine if the current route represents an entity.
  foreach ($route_match->getRouteObject()->getOption('parameters') as $name => $options) {
    if (isset($options['type']) && strpos($options['type'], 'entity:') === 0) {
      $entity = $route_match->getParameter($name);
      if ($entity instanceof ContentEntityInterface) {
        drupal_set_message('This is a content entity');
        if ($entity->isTranslatable()) {
          drupal_set_message('and it is translatable');
        }
      }
      break;
    }
  }

I've translated a page node and a tag taxonomy term. For both, I see the message "This is a content entity," but I don't see "and it is translatable."

Stepping into it with a debugger, I'm seeing that $entity->isTranslatable() is indeed returning false.

What am I missing?

paulmckibben’s picture

Status: Active » Needs review
StatusFileSize
new2.87 KB

I tried a different approach and succeeded. I looked at content_translation_overview() as an example of how to identify translations for an entity.
Patch attached!

I realize I need to add an automated test. I'm new to Drupal 8 core development. For this case, where is the best place to add a test, and is there a good tutorial somebody can point me to, or example code? Thanks!

Status: Needs review » Needs work

The last submitted patch, 3: hreflang-in-head-2303525-3.patch, failed testing.

gábor hojtsy’s picture

Issue tags: +Drupalaton 2014
+++ b/core/modules/content_translation/content_translation.module
@@ -872,3 +872,77 @@ function content_translation_save_settings($settings) {
+function content_translation_get_langcodes($entity) {

This logic is VERY convoluted. Instead IMHO you should use getTranslationLanguages() which gets you all the right languages :)

paulmckibben’s picture

Status: Needs work » Needs review
StatusFileSize
new1.71 KB

Gabor, thanks! If I had only known about that method.
New patch attached.

gábor hojtsy’s picture

Status: Needs review » Needs work
Issue tags: +Needs tests

This looks as good as it can be as far as I see for the code (if it works :D). As for tests, NodeTranslationUITest has nodes translated in various languages and already tests for read more, comment, etc. links. You could test the node pages there for the alternate links as well, should only be a few lines added there.

The last submitted patch, 6: hreflang-in-head-2303525-6.patch, failed testing.

gábor hojtsy’s picture

As for the fails, looks like getOption() on the route may return null if there was no such option. So blindly foreach()ing on it would not work.

paulmckibben’s picture

Status: Needs work » Needs review
StatusFileSize
new3.59 KB

Gabor, thanks as always for the helpful advice.

New patch attached, fixing the foreach() issue that was breaking tests, and also updating NodeTranslationUITest.php to test for the hreflang links.

gábor hojtsy’s picture

We usually couple patch updates on drupal.org with interdiffs, so it is easier to review what changed. It may look too much for such small patches, but it would still help a lot. See https://www.drupal.org/documentation/git/interdiff I think you changed this:

+  foreach ($route_match->getRouteObject()->getOption('parameters') as $name => $options) {

to this:

+  if (!empty($route) && !empty($parameters = $route->getOption('parameters'))) {
+    foreach ($parameters as $name => $options) {

That makes sense. :) Using an interdiff, it is much easier to tell.

Looking forward to test results.

Status: Needs review » Needs work

The last submitted patch, 10: hreflang-in-head-2303525-10.patch, failed testing.

gábor hojtsy’s picture

From http://php.net/manual/en/function.empty.php

Prior to PHP 5.5, empty() only supports variables; anything else will result in a parse error. In other words, the following will not work: empty(trim($name)).

Drupal requires PHP 5.4.x, 5.5 is not required, so this should work with 5.4.x.

paulmckibben’s picture

Status: Needs work » Needs review
StatusFileSize
new3.68 KB
new4.19 KB
new2.6 KB

Gabor, thanks again for your help and patience. Let's try this again.

New patch attached, fixing the PHP 5.4 syntax error, plus two interdiffs:
interdiff-6-to-10.txt shows the diff from the patch in comment 6 to comment 10.
interdiff-10-to-14.txt shows the diff from comment 10 to this comment.

Let's see how the testbot does with this!

gábor hojtsy’s picture

Issue tags: +sprint

Putting on sprint board, since you are working on this :)

gábor hojtsy’s picture

Status: Needs review » Needs work
Issue tags: -Needs tests

This looks very good except this small bit:

+++ b/core/modules/node/src/Tests/NodeTranslationUITest.php
@@ -315,6 +318,26 @@ protected function doTestTranslations($path, array $values) {
+        $link = '<link rel="alternate" hreflang="' . $altlangcode .'" href="' . $url . '" />';
+        $this->assertRaw($link, format_string('The %langcode node translation has the correct alternate hreflang link for %altlangcode: %link.', array('%langcode' => $langcode, '%altlangcode' => $altlangcode, '%link' => $link)));

There is a bit too much concrete markup assumption about how Drupal formats these tags exactly, down to the number of whitespace... If you use an xpath expression, you can test essentially the same thing but with more room for Drupal to adjust markup without needing to fix every detail in tests. Eg. something like

    $links = $this->xpath('//link[@rel = 'alternate' and @href = :href and @hreflang = :hreflang]', array(':href' => $url, ':hreflang' => $altlangcode));
    $this->assert(isset($links[0]), format_string(....)));

Good job! :)

paulmckibben’s picture

Status: Needs work » Needs review
StatusFileSize
new3.82 KB
new1.23 KB

Gabor, thank you! I did not think of using xpath.

New patch uploaded with interdiff. I used a slightly different xpath expression, '/head/link', to specify that the link be a child of the head element.

Status: Needs review » Needs work

The last submitted patch, 17: hreflang-in-head-2303525-17.patch, failed testing.

paulmckibben’s picture

Status: Needs work » Needs review

Testing failed because of a git error. After re-queueing, tests passed. Setting back to Needs Review.

gábor hojtsy’s picture

Status: Needs review » Reviewed & tested by the community

Looks good to me now. Thanks!

sun’s picture

Glad to see you were able to locate the right bracket after which to break: ;-)

+                );
+              }
+            }
+          }
+          break;
+        }
+      }
+    }
+  }
+}

You can avoid excessive nesting by using (1) negated conditions (+ continue; if applicable) and/or (2) early-returns.

paulmckibben’s picture

Sun, thanks for the feedback. Did you want this to go back to "needs work"? Should I submit a new patch?

tim.plunkett’s picture

Status: Reviewed & tested by the community » Needs review
StatusFileSize
new4.5 KB
new3.71 KB

Something like this?

EDIT: @paulmckibben Ahhh! I crossposted with you, and I didn't notice you were assigned. Sorry :(

Status: Needs review » Needs work

The last submitted patch, 24: hreflang-2303525-24.patch, failed testing.

tim.plunkett’s picture

StatusFileSize
new3.59 KB
+++ b/core/modules/system/src/Plugin/Block/SystemPoweredByBlock.php
@@ -25,6 +26,9 @@ class SystemPoweredByBlock extends BlockBase {
   public function build() {
+    $entity = Node::load(1);
+    $language = 'asdf';
+    $foo = $entity->urlInfo()->setOption('language', $language)->setAbsolute()->toString();

Whoops. My test code snuck in there.

tim.plunkett’s picture

Status: Needs work » Needs review
gábor hojtsy’s picture

Status: Needs review » Reviewed & tested by the community

Thanks for reformatting the code for even better readability :) Yay for machine parseable output.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 26: hreflang-2303525-25.patch, failed testing.

Status: Needs work » Needs review
tim.plunkett’s picture

Status: Needs review » Reviewed & tested by the community

Testbot ate that run.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

What about a view that is translated?

+++ b/core/modules/content_translation/content_translation.module
@@ -833,3 +833,43 @@ function content_translation_save_settings($settings) {
+    // Only add links for the first content entity found.
+    return;

How come we do this? When do we find more than one content entity? Perhaps the comment needs updating to say don't check anymore parameters after we've found the one we're looking for :)

tim.plunkett’s picture

When rewriting the code, the break called out in #22 from the patch in #17 became a return.
I added the comment because that's what it does, I'm not sure if it was intended.

sun’s picture

I was confused by @alexpott's reference to views - which shouldn't apply here; except if the view itself would be translated (disregarding translatable entities within).

The quoted condition, however,

+    // Only add links for the first content entity found.
+    return;

…seemingly tries to address a different problem space: If the current route contains multiple upcasted entity parameters, then we don't know what the "actual" (primary) entity of the current page is; e.g.:

/node/{node}/revisions/{node_revision}

So we "blindly" choose the first, in the hope that it's the actual/primary. (which isn't necessarily the case, as e.g. in aforementioned node revision route example)

gábor hojtsy’s picture

@alexpott: a view that is translated in itself does not really guarantee that that page will have language alternate content. That would be assuming quite a bit about how that view is built. Eg. that it is dependent on the page's language. That a view has some of its config translated does not guarantee it actually is displaying language dependent data.

@sun: do you know a way to tell the "primary" entity on the route from the "non-primary" one(s)?

paulmckibben’s picture

I am having trouble making sense of this concern as well. As far as I can tell, there would only be one entity parameter, even if the entity is a node with revisions, or even if the path has a revision ID in it.

@sun, can you explain a way I can test for a condition where there would be multiple entity parameters in the route?

Thank you.

gábor hojtsy’s picture

@paulmckibben: well, looks like we need the comment updated as per #32.

paulmckibben’s picture

Status: Needs work » Needs review
StatusFileSize
new3.66 KB
new3.9 KB

Rerolled patch against updated 8.0.x branch, and updated the comment. Patch and interdiff attached. Interdiff seemed to have some issues with NodeTranslationUITest.php, but there are no new changes to that file. The only real change is the one comment line.

Thanks!
Paul

gábor hojtsy’s picture

Status: Needs review » Reviewed & tested by the community

I think we cleared out above that config entities are not really suitable for this. Also that there is no way to tell which part of the route should be the primary entity, so we don't have a better way but to pick the first. The requested comment update was done, so moving back to RTBC.

paulmckibben’s picture

Thanks, Gabor. I'd like to add, regarding "picking the first entity," in every test I tried, the route only had one entity. In @sun's example of /node/{node}/revisions/{node_revision}, there was no entity on the route at all. Bottom line: I could not find a scenario where the route had more than one entity. If somebody knows of such a scenario, I'd like to learn about it.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 2e7b455 and pushed to 8.0.x. Thanks!

@Gábor Hojtsy and I discussed whether or not there should be an alternate link to the same language as currently being viewed.

14:30 GaborHojtsy
alexpott: according to http://www.w3.org/TR/2014/PR-html5-20140916/links.html#rel-alternate
14:30 GaborHojtsy
"If the alternate keyword is used with the hreflang attribute, and that attribute's value differs from the root element's language, it indicates that the referenced document is a translation."
diff --git a/core/modules/node/src/Tests/NodeTranslationUITest.php b/core/modules/node/src/Tests/NodeTranslationUITest.php
index fa2e8fc..9c0ffac 100644
--- a/core/modules/node/src/Tests/NodeTranslationUITest.php
+++ b/core/modules/node/src/Tests/NodeTranslationUITest.php
@@ -339,11 +339,11 @@ protected function doTestAlternateHreflangLinks($path) {
     }
     foreach ($this->langcodes as $langcode) {
       $this->drupalGet($path, array('language' => $languages[$langcode]));
-      foreach ($urls as $altlangcode => $url) {
+      foreach ($urls as $alternate_langcode => $url) {
         // Retrieve desired link elements from the HTML head.
         $links = $this->xpath('head/link[@rel = "alternate" and @href = :href and @hreflang = :hreflang]',
-          array(':href' => $url, ':hreflang' => $altlangcode));
-        $this->assert(isset($links[0]), format_string('The %langcode node translation has the correct alternate hreflang link for %altlangcode: %link.', array('%langcode' => $langcode, '%altlangcode' => $altlangcode, '%link' => $url)));
+          array(':href' => $url, ':hreflang' => $alternate_langcode));
+        $this->assert(isset($links[0]), format_string('The %langcode node translation has the correct alternate hreflang link for %alternate_langcode: %link.', array('%langcode' => $langcode, '%alternate_langcode' => $alternate_langcode, '%link' => $url)));
       }
     }
   }

I updated the variable name on commit since $altlangcode should be snaked.

  • alexpott committed 2e7b455 on 8.0.x
    Issue #2303525 by paulmckibben, tim.plunkett: Provide link tags to...
gábor hojtsy’s picture

Issue tags: -sprint

Superb, thanks for sticking around on this @paulmckibben, this should be a great move to Drupal 8 multilingual SEO!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

mfb’s picture

It's not clear to me why the hreflang link tags should only be generated for translated entities. Other pages, such as views, and untranslated content with a translated user interface, need them too.

For another solution, this module adds hreflang link tags to all pages on the site: https://www.drupal.org/project/hreflang

andypost’s picture

@mfb please file new feature request for 8.1.x branch!

roam2345’s picture

Should this not be in the head of the html page? The language switcher is not in the of the page, that is what the description on the google page requires.