Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can JMSI18nRoutingBundle use HTTP Accept-Language array?

I'm trying to do an internationalized website, with an URL prefix for each language I translated (eg. /fr/my/page or /it/my/page).

I tried JMSI18nRoutingBundle and it works pretty good with almost no additional configuration. But I really want to determine automatically the user preferred language. The user's favorite languages are transmitted into the Accept-Language HTTP header, and I want to choose the first language I have a translation for.

Here is my JMSI18nRouting config:

jms_i18n_routing:
    default_locale: en
    locales: [fr, en]
    strategy: prefix_except_default

I want this type of behaviour:

http://mywebsite.com/my/page do an automatic language detection then a redirection to /xx/... (where xx is the user favorite language) because language is not specified in URL — Presently the default language is EN.

http://mywebsite.com/XX/my/page shows the page in XX language — Presently, works fine.

Any idea to do this ? Is the config OK ?

Oh, and, if anyone has a solution to do the same thing in pure Symfony (without JMSI18nRoutingBundle), my ears are widely open.

EDIT / Found a way to have intelligent redirections with JMSI18nRoutingBundle to respect user's favorite language or let user force the display of a language. See my answer.

like image 523
Morgan Touverey Quilling Avatar asked Dec 18 '14 00:12

Morgan Touverey Quilling


2 Answers

Finally, I answer my question.

I developed a small "patch" that uses JMSI18nRoutingBundle and detects the user's preferred language, and also let the user force a language.

Create listener YourBundle/EventListener/LocaleListener.php

This listener will change the URL if the user's preferred locale is different to the locale defined by Symfony or JMSI18nRoutingBundle. In this way, you have two URL for two different contents in two different languages : it's SEO friendly.

You can also create a language selector composed of links hrefing to ?setlang=xx where xx is the language the user wants to display. The listener will detect the setlang query and will force the display of the xx lang, including in the next requests.

Note the $this->translatable = [... array. It let you define what parts of your site are translated/translatable. The granularity can be defined from the vendor to the action method. You can also create a config node to define your translatable vendors/bundles/controllers, I don't made this because of performance considerations.

<?php

namespace YourVendor\YourBundle\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class LocaleListener implements EventSubscriberInterface
{
    private $defaultLocale;
    private $acceptedLocales;
    private $translatable;

    public function __construct($router, $defaultLocale, $acceptedLocales)
    {
        $this->router = $router;
        $this->defaultLocale = $defaultLocale;
        $this->acceptedLocales = $acceptedLocales;

        $this->translatable = [
            'Vendor1',
            'Vendor2\Bundle1',
            'Vendor2\Bundle2\Controller1',
            'Vendor2\Bundle2\Controller2::myPageAction',
        ];
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();
        $route = $request->get('_route');

        if(!empty($newLocale = $request->query->get('setlang'))) {
            if(in_array($newLocale, $this->acceptedLocales)) {
                $cookie = new Cookie('force_lang', $newLocale, time() + 3600 * 24 * 7);

                $url = $this->router->generate($route, ['_locale' => $newLocale] + $request->attributes->get('_route_params'));

                $response = new RedirectResponse($url);
                $response->headers->setCookie($cookie);
                $event->setResponse($response);
            }
        } else if($this->translatable($request->attributes->get('_controller'))) {
            $preferred = empty($force = $request->cookies->get('force_lang')) ? $request->getPreferredLanguage($this->acceptedLocales) : $force;

            if($preferred && $request->attributes->get('_locale') != $preferred) {
                $url = $this->router->generate($route, ['_locale' => $preferred] + $request->attributes->get('_route_params'));
                $event->setResponse(new RedirectResponse($url));
            }
        }
    }

    private function translatable($str)
    {
        foreach($this->translatable as $t) {
            if(strpos($str, $t) !== false) return true;
        }

        return false;
    }

    public static function getSubscribedEvents()
    {
        return [ KernelEvents::REQUEST => [['onKernelRequest', 200]] ];
    }
}

Bind your listener on the HTTP kernel.

Edit your services.yml file.

services:
    app.event_listener.locale_listener:
        class: YourVendor\YourBundle\EventListener\LocaleListener
        arguments: ["@router", "%kernel.default_locale%", "%jms_i18n_routing.locales%"]
        tags:
          - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

Configuration of JMSI18nRoutingBundle

You have nothing to change. Example:

# JMS i18n Routing Configuration
jms_i18n_routing:
    default_locale: "%locale%"
    locales: [fr, en]
    strategy: prefix_except_default
like image 51
Morgan Touverey Quilling Avatar answered Nov 15 '22 10:11

Morgan Touverey Quilling


Here's a method to do it using straight Symfony. It might feel a tad hacky because it requires specifying 2 routes per each action, so if someone can think of a better way I'm all ears.

First, I would define some sort of config parameter for all of the acceptable locales, and list the first one as the default

parameters.yml.dist:

parameters:
    accepted_locales: [en, es, fr]

Then make sure your Controller routes match for when _locale is both set and not set. Use the same route name for both, except suffix the one without a _locale with a delimiter like |:

/**
 * @Route("/{_locale}/test/{var}", name="test")
 * @Route(          "/test/{var}", name="test|")
 */
public function testAction(Request $request, $var, $_locale = null)
{
    // whatever your controller action does
}

Next define a service that will listen on the Controller event and pass your accepted locales to it:

<service id="kernel.listener.locale" class="My\Bundle\EventListener\LocaleListener">
    <tag name="kernel.event_listener" event="kernel.controller" method="onKernelController" />
    <argument>%accepted_locales%</argument>
</service>

Now use the service to detect if _locale is set in your route, and if not, determine the locale based on the HTTP_ACCEPT_LANGUAGE header and redirect to the route that contains it. Here's an example listener that will do this (I added comments to explain what I was doing):

namespace NAB\UtilityBundle\EventListener;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;

class ControllerListener
{
    private $acceptedLocales;

    public function __construct(array $acceptedLocales)
    {
        $this->acceptedLocales = $acceptedLocales;
    }

    public function onKernelController(FilterControllerEvent $event)
    {
        if (HttpKernelInterface::MASTER_REQUEST != $event->getRequestType()) {
            return;
        }

        $controller = $event->getController();

        if (!is_array($controller)) {
            return;
        }

        $request = $event->getRequest();
        $params  = $request->attributes->get('_route_params');

        // return if _locale is already set on the route
        if ($request->attributes->get('_locale')) {
            return;
        }

        // if the user has accepted languages set, set the locale on the first match found
        $languages = $request->server->get('HTTP_ACCEPT_LANGUAGE');

        if (!empty($languages))
        {
            foreach (explode(',', $languages) as $language) 
            {
                $splits  = array();
                $pattern = '/^(?P<primarytag>[a-zA-Z]{2,8})(?:-(?P<subtag>[a-zA-Z]{2,8}))?(?:(?:;q=)(?P<quantifier>\d\.\d))?$/';

                // if the user's locale matches the accepted locales, set _locale in the route params
                if (preg_match($pattern, $language, $splits) && in_array($splits['primarytag'], $this->acceptedLocales)) 
                {
                    $params['_locale'] = $splits['primarytag'];

                    // stop checking once the first match is found
                    break;
                }
            }
        }

        // if no locale was found, default to the first accepted locale
        if (!$params['_locale']) {
            $params['_locale'] = $this->acceptedLocales[0];
        }

        // drop the '|' to get the appropriate route name
        list($localeRoute) = explode('|', $request->attributes->get('_route'));

        // attempt get the redirect URL but return if it could not be found
        try {
            $redirectUrl = $controller[0]->generateUrl($localeRoute, $params);
        }
        catch (\Exception $e) {
            return;
        }

        // set the controller response to redirect to the route we just created
        $event->setController(function() use ($redirectUrl) {
            return new RedirectResponse($redirectUrl);
        });
    }
}

For further explanation on setting up a before filter on a Controller, check out the Symfony documentation here. If you use something like this, be very careful that every route name is defined properly.

like image 28
Jason Roman Avatar answered Nov 15 '22 10:11

Jason Roman