xp-forge / handlebars-templates

Handlebars templates for XP web frontends
1 stars 0 forks source link

L10N & I18N #1

Open thekid opened 3 years ago

thekid commented 3 years ago

See https://preview.npmjs.com/package/handlebars-i18n as a good example, though we might abstract the text file into a Translations interface and implementations for YAML, CSV etcetera.

thekid commented 3 years ago
$h= new Handlebars($path, (new I18n())
  ->dates('d.m.Y')
  ->times('H:i')
  ->separator(',')
  ->thousands('.')
  ->prices('%.2f EUR')
);

⚠️ Incomplete - date and time handling needs more than this, see https://www.php.net/manual/de/intldateformatter.create.php

thekid commented 3 years ago

Translations idea:

(new Handlebars($path))->using(new Translations('texts.csv'));
thekid commented 3 years ago

Language would need to be determined from the request:

In both scenarios, we could pass the determined language in request values and then react on that inside this template engine.

Telling the template engine where to select the language from could be done by passing a function(web.Request): string to it.

thekid commented 3 years ago

⚠️ Incomplete - date and time handling needs more than this, see https://www.php.net/manual/de/intldateformatter.create.php

This has been taken care of in #3

thekid commented 3 years ago

Example implementation of translations extension:

<?php namespace org\example\skills;

use io\Path;
use io\streams\{TextReader, FileInputStream};
use text\csv\CsvMapReader;
use web\frontend\helpers\Extension;

class Translations extends Extension {
  private $original;
  private $texts= [];

  /** Creates new translations from a CSV file */
  public function __construct(string|Path $file) {
    $reader= new CsvMapReader(new TextReader(new FileInputStream($file), 'utf-8'));
    $translations= $reader->getHeaders();
    $this->original= $translations[0];

    foreach ($reader->withKeys($translations)->lines() as $record) {
      $this->texts[$record[$this->original]]= $record;
    }
  }

  /** Returns "t" helper */
  public function helpers(): iterable {
    yield 't' => function($in, $context, $options) {
      $lang= $context->lookup('request', helpers: false)->value('user')['language'] ?? $this->original;
      $text= array_shift($options);
      return vsprintf($this->texts[$text][$lang] ?? $text, $options);
    };
  }
}
{{t "Welcome %s!" self.name}}
{{t "Search users and skills..."}}
en;de
"Welcome %s!";"Willkommen %s!"
"Search users and skills...";"Nutzer und Skills durchsuchen..."

I chose to use CSV because there are enough GUIs to edit these files, even by not-too-technical folks.

thekid commented 3 years ago

I chose to use CSV because there are enough GUIs to edit these files, even by not-to-technical folks.

If we refactored this to accept a Texts source, we could also easily support other formats like e.g. JSON, YAML, PO files, XLIFF and others.

thekid commented 3 years ago

$lang= $context->lookup('request', helpers: false)->value('user')['language'] ?? $this->original;

⚠️ This seems a) like a "heavy" operation and b) could not serve as a general-purpose implementation since it has knowledge of the user object.

thekid commented 3 years ago

This seems a) like a "heavy" operation

One idea would be to pass an individual context so that this could be rewritten to $context->request->..., for example. However, contexts spawn child contexts, which are new instances, e.g. a HashContext, which handles e.g. lookup of @key.

The other idea would be to have like a "scoped" engine, and then have: $context->engine->scope->... or so. This is most probably easier, because instead of calling $engine->write($template, $context, $out) (which internally passes $this along with the context) we could invoke $template->write($c->withEngine(new Scoped($engine, $request)), $out);

thekid commented 3 years ago

The other idea would be to have like a "scoped" engine

...which means the helper would be dependant on a certain engine implementation, which is OK as we control the engine and the helpers inside this library.


Here's what we can do on the other hand: First, we need to construct templates with a function:

$translation= new Translation(
  $texts,
  fn($context) => $context->lookup('request', helpers: false)->value('user')['language'] ?? null
);

Second, we can cache the results of this function inside the context:

yield 't' => function($in, $context, $options) {
  $lang= $context->variables['lang'] ??= ($this->language)($context) ?? $this->original;
  // ...
}
thekid commented 3 years ago

The problem with the date and number helpers are that they do not take user preference into mind. Maybe this would work:

// Always uses "d.m.Y" as date format
$engine= new Handlebars($templates, new Dates(formats: ['short' => 'd.m.Y']));

// Use locale from user object, would try [lang]_[region], [lang], then fall back to null
$engine= new Handlebars($templates, new ByLocale(
  fn($context) => $context->lookup('request', helpers: false)->value('user')['locale'],
  [
    'en_US' => [new Numbers('.', ','), new Dates(formats: ['short' => 'm/d/Y'])],
    'de'    => [new Numbers(',', '.'), new Dates(formats: ['short' => 'd.m.Y'])],
    null    => [new Numbers(), new Dates(formats: ['short' => 'd.m.Y'])],
  ]
));

// Using the "intl" extension (https://www.php.net/manual/de/book.intl.php)
$engine= new Handlebars($templates, new ByLocale(
  fn($context) => $context->lookup('request', helpers: false)->value('user')['locale'],
  fn($locale) => [Numbers::using(new NumberFormatter($locale)), Dates::using(new IntlDateFormatter($locale))],
));

The locale could also be initially detected by looking at Accept-Language (and then have the user refine it, see https://www.w3.org/International/questions/qa-accept-lang-locales.en)