Usar Symfony Translations fuera de Symfony2

Si algo me está sorprendiendo de PHP en estos últimos meses y me hace mantener la poca esperanza que todavía tengo en este obsoleto lenguaje, es Symfony2. El framework más interesante con el que me he topado por el momento para PHP pero, mejor todavía que el framework, es la estrategia de los Componentes que se pueden desacoplar para usar de forma aislada en cualquier proyecto PHP.

Y como no podía ser de otra forma, había que probarlo, así que en una especie de pet-project he intentado integrar el componente de las traducciones (Translation). Los pasos han sido los siguientes:

Instalación del componente y dependencias

Nos aprovechamos de Composer para agregar a nuestro entorno (vacío de momento) los siguientes componentes descritos en el composer.json:

{
  "require": {
    "symfony/console": "2.*",
    "symfony/config": "2.*",
    "symfony/finder": "2.*",
    "symfony/class-loader": "2.1.*",
    "symfony/translation": "2.*",
    "symfony/yaml": "2.*"
  }
}
$ composer install
$ composer update

Autoload de las clases necesarias

El síndrome del folio en blanco en PHP empieza con un index.php en el que cargamos el ClassLoader de Symfony, registramos los namespaces de los componentes que hemos instalado en el paso previo y dejamos todo listo para empezar la diversión:

// Autoloader
require_once __DIR__.'/vendor/symfony/class-loader/Symfony/Component/ClassLoader/UniversalClassLoader.php';

use Symfony\Component\ClassLoader\UniversalClassLoader;

$loader = new UniversalClassLoader();
$loader->register();
$loader->registerNamespace('Symfony\\Component\\Finder', __DIR__.'/vendor/symfony/finder');
$loader->registerNamespace('Symfony\\Component\\Config', __DIR__.'/vendor/symfony/config');
$loader->registerNamespace('Symfony\\Component\\Translation', __DIR__.'/vendor/symfony/translation');
$loader->registerNamespace('Symfony\\Component\\Yaml', __DIR__.'/vendor/symfony/yaml');
// Autoloader

// Init
use Symfony\Component\Translation\Translator;
use Symfony\Component\Translation\MessageSelector;
use Symfony\Component\Translation\Loader\YamlFileLoader;
// Init

Seleccionando idioma y cargando los archivos de idioma

Lo siguiente es seleccionar el idioma principal de la página y cargar los archivos donde tenemos todas las cadenas de traducción:

$translator = new Translator('en', new MessageSelector());
$translator->addLoader('yaml', new YamlFileLoader());

if(is_file(__DIR__.'/locale/messages.en.yml')) $translator->addResource('yaml', __DIR__.'/locale/messages.en.yml', 'en');
if(is_file(__DIR__.'/locale_custom/messages.en.yml')) $translator->addResource('yaml', __DIR__.'/locale_custom/messages.en.yml', 'en');

if(is_file(__DIR__.'/locale/messages.es.yml')) $translator->addResource('yaml', __DIR__.'/locale/messages.es.yml', 'es');
if(is_file(__DIR__.'/locale_custom/messages.es.yml')) $translator->addResource('yaml', __DIR__.'/locale_custom/messages.es.yml', 'es');

En este caso he querido probar a cargar 2 archivos distintos de cada idioma para ver el comportamiento hereditario de variables duplicadas. Y las de la segunda carga prevalecen a las primeras tal cual me esperaba.

Referenciando cadenas de traducción

Tan sólo nos falta hacer referencia a las variables de traducción:

echo $translator->trans('symfony2.great');

Como anteriormente hemos seleccionado inglés como idioma principal, el componente llegado a este punto buscará la cadena "symfony2.great" en los archivos locale/messages.en.yml y en locale_custom/messages.en.yml y se encargará de reemplazarlo por la cadena correspondiente.

Traduciendo, los archivos de traducción messages.XX.yml

¿Y cómo creamos y/o rellenamos barra traducimos esos archivos messages.XX.yml?. Podemos hacerlo manualmente o, aprovechando que hemos importado la consola de Symfony2, crear un comando de consola que recorra todas las vistas en busca de cadenas de traducción para generar automáticamente dichos messages.XX.yml. Así ha quedado mi console.php:

// console.php

// Autoloader
require_once __DIR__.'/vendor/symfony/class-loader/Symfony/Component/ClassLoader/UniversalClassLoader.php';

use Symfony\Component\ClassLoader\UniversalClassLoader;

$loader = new UniversalClassLoader();
$loader--->register();
$loader->registerNamespace('Symfony\\Component\\Finder', __DIR__.'/vendor/symfony/finder');
$loader->registerNamespace('Symfony\\Component\\Config', __DIR__.'/vendor/symfony/config');
$loader->registerNamespace('Symfony\\Component\\Console', __DIR__.'/vendor/symfony/console');
$loader->registerNamespace('Symfony\\Component\\Translation', __DIR__.'/vendor/symfony/translation');
$loader->registerNamespace('Symfony\\Component\\Yaml', __DIR__.'/vendor/symfony/yaml');
// Autoloader

use Symfony\Component\Finder\Finder;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Loader\LoaderInterface;

$console = new Application();

/*** translation:update *******************************************************/
function normalizeToken($token)
{
    if (is_array($token)) {
        return $token[1];
    }

    return $token;
}

function parseTokens($tokens, MessageCatalogue $catalog, $sequences, $prefix)
{
    foreach ($tokens as $key => $token) {
        foreach ($sequences as $sequence) {
            $message = '';

            foreach ($sequence as $id => $item) {
                if (normalizeToken($tokens[$key + $id]) == $item) {
                    continue;
                } elseif (300 == $item) {
                    $message = normalizeToken($tokens[$key + $id]);
                } elseif (400 == $item) {
                    continue;
                } else {
                    break;
                }
            }

            $message = trim($message, '\'');

            if ($message) {
                $catalog->set($message, $prefix.$message);
                break;
            }
        }
    }
}

function addLoader($format, LoaderInterface $loader, $loaders)
{
    $loaders[$format] = $loader;
    return $loaders;
}

$console
  ->register('translation:update')
  ->setDefinition(array(
                new InputArgument('locale', InputArgument::REQUIRED, 'The locale'),
                //new InputArgument('bundle', InputArgument::REQUIRED, 'The bundle where to load the messages'),
                new InputOption(
                    'prefix', null, InputOption::VALUE_OPTIONAL,
                    'Override the default prefix', '__'
                ),
                new InputOption(
                    'output-format', null, InputOption::VALUE_OPTIONAL,
                    'Override the default output format', 'yml'
                ),
                /*new InputOption(
                    'dump-messages', null, InputOption::VALUE_NONE,
                    'Should the messages be dumped in the console'
                ),*/
                new InputOption(
                    'force', null, InputOption::VALUE_NONE,
                    'Should the update be done'
                )
    ))
  ->setDescription('Updates the translation file')
  ->setHelp(<<%command.name% command extract translation strings from templates
of a given bundle. It can display them or merge the new ones into the translation files.
When new translation strings are found it can automatically add a prefix to the translation
message.

php %command.full_name% --dump-messages en AcmeBundle
php %command.full_name% --force --prefix="new_" fr AcmeBundle
EOF
  )
  ->setCode(function (InputInterface $input, OutputInterface $output) {
        // check presence of force or dump-message
        if ($input->getOption('force') !== true && $input->getOption('dump-messages') !== true) {
            $output->writeln('You must choose one of --force or --dump-messages');

            return 1;
        }

        // check format
        $writer = new \Symfony\Component\Translation\Writer\TranslationWriter();
        $writer->addDumper('yml', new \Symfony\Component\Translation\Dumper\YamlFileDumper());
        //$writer->addDumpernew \Symfony\Component\Translation\Dumper\YamlFileDumper();
        $supportedFormats = $writer->getFormats();
        if (!in_array($input->getOption('output-format'), $supportedFormats)) {
            $output->writeln('Wrong output format');
            $output->writeln('Supported formats are '.implode(', ', $supportedFormats).'.');

            return 1;
        }

        // get bundle directory
        $bundleTransPath = '.';

        // create catalogue
        $catalogue = new MessageCatalogue($input->getArgument('locale'));

        // load any messages from templates
        $output->writeln('Parsing templates');
        $directory='./views/';
        $prefix = '';
        $prefix = $input->getOption('prefix');
        $sequences = array(
              array('$view','[','\'translator\'',']','->','trans','(',300,')',),
              array('$translator','->','trans','(',300,')',),
              array('lang','(',300,')',),
        );
        $finder = new Finder();
        $files = $finder->files()->name('*.php')->in($directory);
        foreach ($files as $file) {
            parseTokens(token_get_all(file_get_contents($file)), $catalogue, $sequences, $prefix);
        }

        // load any existing messages from the translation files
        $output->writeln('Loading translation files');
        $loaders = array();
        $loaders = addLoader('yml', new \Symfony\Component\Translation\Loader\YamlFileLoader(), $loaders);
        foreach ($loaders as $format => $loader) {
            // load any existing translation files
            $finder = new Finder();
            $extension = $catalogue->getLocale().'.'.$format;
            $files = $finder->files()->name('*.'.$extension)->in($bundleTransPath);
            foreach ($files as $file) {
                $domain = substr($file->getFileName(), 0, -1 * strlen($extension) - 1);
                $catalogue->addCatalogue($loader->load($file->getPathname(), $catalogue->getLocale(), $domain));
            }
        }

        // save the files
        if ($input->getOption('force') === true) {
            $output->writeln('Writing files');
            $writer->writeTranslations($catalogue, $input->getOption('output-format'), array('path' => __DIR__.'/locale/'.$bundleTransPath));
        }
  });

/*** console->run *************************************************************/

$console->run();

Particularidades del comando: busca todas las cadenas de traducción en views/*.php y las carga en locale/messages.XX.yml. Y para ejecutar dicho comando haríamos algo tal que así:

$ php console.php translation:update --force es
$ php console.php translation:update --force fr

Conclusiones

Varias. En primer lugar decir que he organizado un poco más todo este código en un repositorio de bitbucket, por si pudiera interesar. Hacía mucho tiempo que no me divertía con código PHP. Reconozco el acierto tanto de Symfony2 como framework (bajo mi punto de vista era algo que necesitaba PHP), como de la fantástica estrategia de los Componentes. Muy a tener en cuenta para cualquier proyecto PHP que pueda salir.

Aunque si he de ser sincero, me sigo encontrando más agusto en otros entornos.

About the author

Óscar
has doubledaddy super powers, father of Hugo and Nico, husband of Marta, *nix user, Djangonaut and open source passionate.