Skip to content

Commit

Permalink
added support for locale, affects |localDate, |number, |bytes and |so…
Browse files Browse the repository at this point in the history
…rt filters
  • Loading branch information
dg committed Aug 6, 2024
1 parent 930973e commit bfc0115
Show file tree
Hide file tree
Showing 9 changed files with 339 additions and 52 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"ext-iconv": "to use filters |reverse, |substring",
"ext-mbstring": "to use filters like lower, upper, capitalize, ...",
"ext-fileinfo": "to use filter |datastream",
"ext-intl": "to use Latte\\Engine::setLocale()",
"nette/utils": "to use filter |webalize",
"nette/php-generator": "to use tag {templatePrint}"
},
Expand Down
20 changes: 20 additions & 0 deletions src/Latte/Engine.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class Engine
private bool $sandboxed = false;
private ?string $phpBinary = null;
private ?string $cacheKey;
private ?string $locale = null;


public function __construct()
Expand Down Expand Up @@ -565,6 +566,25 @@ public function isStrictParsing(): bool
}


/**
* Sets the locale. It uses the same identifiers as the PHP intl extension.
*/
public function setLocale(?string $locale): static
{
if ($locale && !extension_loaded('intl')) {
throw new RuntimeException("Setting a locale requires the 'intl' extension to be installed.");
}
$this->locale = $locale;
return $this;
}


public function getLocale(): ?string
{
return $this->locale;
}


public function setLoader(Loader $loader): static
{
$this->loader = $loader;
Expand Down
9 changes: 8 additions & 1 deletion src/Latte/Essential/CoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ public function beforeCompile(Latte\Engine $engine): void
}


public function beforeRender(Runtime\Template $template): void
{
$this->filters->locale = $template->getEngine()->getLocale();
}


public function getTags(): array
{
return [
Expand Down Expand Up @@ -139,10 +145,11 @@ public function getFilters(): array
'join' => [$this->filters, 'implode'],
'last' => [$this->filters, 'last'],
'length' => [$this->filters, 'length'],
'localDate' => [$this->filters, 'localDate'],
'lower' => extension_loaded('mbstring')
? [$this->filters, 'lower']
: fn() => throw new RuntimeException('Filter |lower requires mbstring extension.'),
'number' => 'number_format',
'number' => [$this->filters, 'number'],
'padLeft' => [$this->filters, 'padLeft'],
'padRight' => [$this->filters, 'padRight'],
'query' => [$this->filters, 'query'],
Expand Down
100 changes: 91 additions & 9 deletions src/Latte/Essential/Filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
*/
final class Filters
{
public ?string $locale = null;


/**
* Converts HTML to plain text.
*/
Expand Down Expand Up @@ -168,14 +171,11 @@ public static function repeat(FilterInfo $info, $s, int $count): string
*/
public static function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?string $format = null): ?string
{
$format ??= Latte\Runtime\Filters::$dateFormat;
if ($time == null) { // intentionally ==
return null;
}

$format ??= Latte\Runtime\Filters::$dateFormat;
if ($time instanceof \DateInterval) {
} elseif ($time instanceof \DateInterval) {
return $time->format($format);

} elseif (is_numeric($time)) {
$time = (new \DateTime)->setTimestamp((int) $time);
} elseif (!$time instanceof \DateTimeInterface) {
Expand All @@ -194,10 +194,75 @@ public static function date(string|int|\DateTimeInterface|\DateInterval|null $ti
}


/**
* Date/time formatting according to locale.
*/
public function localDate(
string|int|\DateTimeInterface|null $value,
?string $format = null,
?string $date = null,
?string $time = null,
): ?string
{
if ($this->locale === null) {
throw new Latte\RuntimeException('Filter |localDate requires the locale to be set using Engine::setLocale()');
} elseif ($value == null) { // intentionally ==
return null;
} elseif (is_numeric($value)) {
$value = (new \DateTime)->setTimestamp((int) $value);
} elseif (!$value instanceof \DateTimeInterface) {
$value = new \DateTime($value);
$errors = \DateTime::getLastErrors();
if (!empty($errors['warnings'])) {
throw new \InvalidArgumentException(reset($errors['warnings']));
}
}

if ($format === null) {
$xlt = ['' => \IntlDateFormatter::NONE, 'full' => \IntlDateFormatter::FULL, 'long' => \IntlDateFormatter::LONG, 'medium' => \IntlDateFormatter::MEDIUM, 'short' => \IntlDateFormatter::SHORT,
'relative-full' => \IntlDateFormatter::RELATIVE_FULL, 'relative-long' => \IntlDateFormatter::RELATIVE_LONG, 'relative-medium' => \IntlDateFormatter::RELATIVE_MEDIUM, 'relative-short' => \IntlDateFormatter::RELATIVE_SHORT];
$date ??= $time === null ? 'long' : null;
$options = [$xlt[$date], $xlt[$time]];
} else {
$options = (new \IntlDatePatternGenerator($this->locale))->getBestPattern($format);
}

$res = \IntlDateFormatter::formatObject($value, $options, $this->locale);
$res = preg_replace('~(\d\.) ~', "\$1\u{a0}", $res);
return $res;
}


/**
* Formats a number with grouped thousands and optionally decimal digits according to locale.
*/
public function number(
float $number,
string|int $patternOrDecimals = 0,
string $decimalSeparator = '.',
string $thousandsSeparator = ',',
): string
{
if (is_int($patternOrDecimals) && $patternOrDecimals < 0) {
throw new Latte\RuntimeException('Filter |number: the number of decimal must not be negative');
} elseif ($this->locale === null || func_num_args() > 2) {
return number_format($number, $patternOrDecimals, $decimalSeparator, $thousandsSeparator);
}

$formatter = new \NumberFormatter($this->locale, \NumberFormatter::DECIMAL);
if (is_string($patternOrDecimals)) {
$formatter->setPattern($patternOrDecimals);
} else {
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $patternOrDecimals);
}
return $formatter->format($number);
}


/**
* Converts to human-readable file size.
*/
public static function bytes(float $bytes, int $precision = 2): string
public function bytes(float $bytes, int $precision = 2): string
{
$bytes = round($bytes);
$units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
Expand All @@ -209,7 +274,15 @@ public static function bytes(float $bytes, int $precision = 2): string
$bytes /= 1024;
}

return round($bytes, $precision) . ' ' . $unit;
if ($this->locale === null) {
$bytes = (string) round($bytes, $precision);
} else {
$formatter = new \NumberFormatter($this->locale, \NumberFormatter::DECIMAL);
$formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $precision);
$bytes = $formatter->format($bytes);
}

return $bytes . ' ' . $unit;
}


Expand Down Expand Up @@ -455,7 +528,7 @@ public static function batch(iterable $list, int $length, $rest = null): \Genera
* @param iterable<K, V> $data
* @return iterable<K, V>
*/
public static function sort(
public function sort(
iterable $data,
?\Closure $comparison = null,
string|int|\Closure|null $by = null,
Expand All @@ -469,7 +542,16 @@ public static function sort(
$by = $byKey === true ? null : $byKey;
}

$comparison ??= fn($a, $b) => $a <=> $b;
if ($comparison) {
} elseif ($this->locale === null) {
$comparison = fn($a, $b) => $a <=> $b;
} else {
$collator = new \Collator($this->locale);
$comparison = fn($a, $b) => is_string($a) && is_string($b)
? $collator->compare($a, $b)
: $a <=> $b;
}

$comparison = match (true) {
$by === null => $comparison,
$by instanceof \Closure => fn($a, $b) => $comparison($by($a), $by($b)),
Expand Down
16 changes: 13 additions & 3 deletions tests/filters/bytes.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,20 @@ use Tester\Assert;
require __DIR__ . '/../bootstrap.php';


Assert::same('0 B', Filters::bytes(0.1));
test('no locale', function () {
$filters = new Filters;

Assert::same('0 B', $filters->bytes(0.1));
Assert::same('-1.03 GB', $filters->bytes(-1024 * 1024 * 1050));
Assert::same('8881.78 PB', $filters->bytes(1e19));
});

Assert::same('-1.03 GB', Filters::bytes(-1024 * 1024 * 1050));

test('with locale', function () {
$filters = new Filters;
$filters->locale = 'cs_CZ';

Assert::same('8881.78 PB', Filters::bytes(1e19));
Assert::same('0 B', $filters->bytes(0.1));
Assert::same('-1,03 GB', $filters->bytes(-1024 * 1024 * 1050));
Assert::same('8 881,78 PB', $filters->bytes(1e19));
});
41 changes: 20 additions & 21 deletions tests/filters/date.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,31 @@ use Tester\Assert;
require __DIR__ . '/../bootstrap.php';


setlocale(LC_TIME, 'C');
test('datatypes', function () {
Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date('1978-05-05'));
Assert::same("23.\u{a0}1.\u{a0}1978", Filters::date(254_400_000));
Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date(new DateTime('1978-05-05')));
Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date(new DateTimeImmutable('1978-05-05')));
});


Assert::null(Filters::date(null));
test('edge cases', function () {
Assert::null(Filters::date(null));
Assert::null(Filters::date(''));
});


Assert::same("23.\u{a0}1.\u{a0}1978", Filters::date(254_400_000));
test('timestamp & zone', function () {
date_default_timezone_set('America/Los_Angeles');
Assert::same('07:09', Filters::date(1_408_284_571, 'H:i'));
});


Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date('1978-05-05'));
test('date/time formatting', function () {
Assert::same('1212-09-26', Filters::date('1212-09-26', 'Y-m-d'));
});


Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date(new DateTime('1978-05-05')));


Assert::same('1978-01-23', Filters::date(254_400_000, 'Y-m-d'));


Assert::same('1212-09-26', Filters::date('1212-09-26', 'Y-m-d'));


Assert::same('1212-09-26', Filters::date(new DateTimeImmutable('1212-09-26'), 'Y-m-d'));


Assert::same('30:10:10', Filters::date(new DateInterval('PT30H10M10S'), '%H:%I:%S'));


date_default_timezone_set('America/Los_Angeles');
Assert::same('07:09', Filters::date(1_408_284_571, 'H:i'));
test('interval', function () {
Assert::same('30:10:10', Filters::date(new DateInterval('PT30H10M10S'), '%H:%I:%S'));
});
96 changes: 96 additions & 0 deletions tests/filters/localDate.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

/**
* Test: Latte\Essential\Filters::localDate()
* @phpVersion 8.1
*/

declare(strict_types=1);

use Latte\Essential\Filters;
use Tester\Assert;

require __DIR__ . '/../bootstrap.php';


test('datatypes', function () {
$filters = new Filters;
$filters->locale = 'cs_CZ';

Assert::same("5.\u{a0}května 1978", $filters->localDate('1978-05-05'));
Assert::same("23.\u{a0}ledna 1978", $filters->localDate(254_400_000));
Assert::same("5.\u{a0}května 1978", $filters->localDate(new DateTime('1978-05-05')));
Assert::same("5.\u{a0}května 1978", $filters->localDate(new DateTimeImmutable('1978-05-05')));
});


test('edge cases', function () {
$filters = new Filters;
$filters->locale = 'cs_CZ';

Assert::null($filters->localDate(null));
Assert::null($filters->localDate(''));
Assert::exception(
fn() => $filters->localDate('2024-02-31'),
InvalidArgumentException::class,
'The parsed date was invalid',
);
});


test('timestamp & zone', function () {
$filters = new Filters;
$filters->locale = 'cs_CZ';

date_default_timezone_set('America/Los_Angeles');
Assert::same('7:09', $filters->localDate(1_408_284_571, 'jm'));
});


test('skeleton pattern', function () {
$filters = new Filters;
$filters->locale = 'cs_CZ';

Assert::same('květen', $filters->localDate('1978-05-05', 'MMMM'));
Assert::same('5. května', $filters->localDate('1978-05-05', 'd MMMM'));
Assert::same('květen 78', $filters->localDate('1978-05-05', 'MMMM yy'));
});


test('full/long/medium/short', function () {
$filters = new Filters;
$filters->locale = 'cs_CZ';

// date format
Assert::same('05.05.78', $filters->localDate('1978-05-05', date: 'short'));
Assert::same("5.\u{a0}5.\u{a0}1978", $filters->localDate('1978-05-05', date: 'medium'));
Assert::same("5.\u{a0}května 1978", $filters->localDate('1978-05-05', date: 'long'));
Assert::same("pátek 5.\u{a0}května 1978", $filters->localDate('1978-05-05', date: 'full'));

// time format
Assert::same('12:13', $filters->localDate('12:13:14', time: 'short'));
Assert::same('12:13:14', $filters->localDate('12:13:14', time: 'medium'));
Assert::same('12:13:14 PDT', $filters->localDate('12:13:14', time: 'long'));
Assert::match('12:13:14%a%', $filters->localDate('12:13:14', time: 'full'));

// combined
$filters->locale = 'en_US';
Assert::match('5/5/78, 12:13%a%PM', $filters->localDate('1978-05-05 12:13:14', date: 'short', time: 'short'));
});


test('relative full/long/medium/short', function () {
$filters = new Filters;
$filters->locale = 'cs_CZ';

Assert::same('05.05.78', $filters->localDate('1978-05-05', date: 'relative-short'));
Assert::same("5.\u{a0}5.\u{a0}1978", $filters->localDate('1978-05-05', date: 'relative-medium'));
Assert::same("5.\u{a0}května 1978", $filters->localDate('1978-05-05', date: 'relative-long'));
Assert::same("pátek 5.\u{a0}května 1978", $filters->localDate('1978-05-05', date: 'relative-full'));

$now = new DateTime;
Assert::same('dnes', $filters->localDate($now, date: 'relative-short'));
Assert::same('dnes', $filters->localDate($now, date: 'relative-medium'));
Assert::same('dnes', $filters->localDate($now, date: 'relative-long'));
Assert::same('dnes', $filters->localDate($now, date: 'relative-full'));
});
Loading

0 comments on commit bfc0115

Please sign in to comment.