mirror of
https://github.com/twigphp/Twig.git
synced 2026-08-30 20:16:45 +00:00
Honor date formatter prototype calendars
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
* Deprecate the `sandboxed` argument of the `include` function, use `Twig\Sandbox\Sandbox` instead
|
||||
* Deprecate `SandboxExtension::enableSandbox()`, `disableSandbox()`, and `isSandboxedGlobally()`
|
||||
* Normalize destructuring variable AST nodes as assignment targets
|
||||
* Fix `IntlExtension` ignoring explicit date/time formats and the `format_date`/`format_time` filters when a date formatter prototype is configured
|
||||
* Fix `IntlExtension` ignoring explicit date/time formats and configured calendars when using a date formatter prototype
|
||||
* Add a `format_list` filter to `IntlExtension` to format a list of strings using PHP 8.5's `IntlListFormatter`
|
||||
* Fix array access with a `Stringable` key coercing the key to string for `ArrayAccess` objects that use object keys (such as `SplObjectStorage`)
|
||||
* Fix duplicated macro argument names triggering a PHP fatal error instead of a `SyntaxError`
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
``format_date``
|
||||
===============
|
||||
|
||||
The ``format_date`` filter formats a date. It behaves in the exact same way as
|
||||
the :doc:`format_datetime<format_datetime>` filter, but without the time.
|
||||
The ``format_date`` filter formats a date. It supports the same locale,
|
||||
timezone, calendar and format options as the
|
||||
:doc:`format_datetime<format_datetime>` filter, but without the time.
|
||||
|
||||
When no format or pattern is provided, Twig uses the
|
||||
:ref:`application default date format <intl-date-format-defaults>`, or
|
||||
``medium`` when none is configured. To use a custom pattern, pass it explicitly.
|
||||
The :ref:`application default pattern <intl-date-format-defaults>` is not used
|
||||
for date-only formatting.
|
||||
|
||||
.. note::
|
||||
|
||||
@@ -31,8 +38,8 @@ Arguments
|
||||
|
||||
* ``locale``: The locale code as defined in `RFC 5646`_
|
||||
* ``dateFormat``: The date format
|
||||
* ``pattern``: A date time pattern
|
||||
* ``pattern``: A date pattern
|
||||
* ``timezone``: The date timezone
|
||||
* ``calendar``: The calendar ("gregorian" by default)
|
||||
* ``calendar``: The calendar
|
||||
|
||||
.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
|
||||
|
||||
@@ -30,7 +30,6 @@ The ``format_datetime`` filter formats a date time:
|
||||
$twig = new \Twig\Environment(...);
|
||||
$twig->addExtension(new IntlExtension());
|
||||
|
||||
|
||||
Format
|
||||
------
|
||||
|
||||
@@ -49,6 +48,10 @@ You can tweak the output for the date part and the time part:
|
||||
|
||||
Supported values are: ``none``, ``short``, ``medium``, ``long``, and ``full``.
|
||||
|
||||
When no pattern is provided, each omitted format uses the corresponding
|
||||
:ref:`application default <intl-date-format-defaults>`, or ``medium`` when none
|
||||
is configured.
|
||||
|
||||
.. versionadded:: 3.6
|
||||
|
||||
``relative_short``, ``relative_medium``, ``relative_long``, and ``relative_full`` are also supported when running on
|
||||
@@ -60,19 +63,38 @@ For greater flexibility, you can even define your own pattern
|
||||
|
||||
.. code-block:: twig
|
||||
|
||||
{# 11 oclock PM, GMT #}
|
||||
{# 11 o'clock PM, GMT #}
|
||||
{{ '2019-08-07 23:39:12'|format_datetime(pattern: "hh 'oclock' a, zzzz") }}
|
||||
|
||||
When no pattern, date format or time format is provided, Twig uses the
|
||||
:ref:`application default pattern <intl-date-format-defaults>`, if any.
|
||||
|
||||
Locale
|
||||
------
|
||||
|
||||
By default, the filter uses the current locale. You can pass it explicitly:
|
||||
By default, the filter uses the
|
||||
:ref:`application default locale <intl-date-format-defaults>`, or the current
|
||||
locale when none is configured. You can override it explicitly:
|
||||
|
||||
.. code-block:: twig
|
||||
|
||||
{# 7 août 2019 23:39:12 #}
|
||||
{{ '2019-08-07 23:39:12'|format_datetime(locale: 'fr') }}
|
||||
|
||||
Calendar
|
||||
--------
|
||||
|
||||
By default, the filter uses the
|
||||
:ref:`application default calendar <intl-date-format-defaults>`, or the
|
||||
Gregorian calendar when none is configured. You can override it explicitly:
|
||||
|
||||
.. code-block:: twig
|
||||
|
||||
{{ '2019-08-07 23:39:12'|format_datetime(
|
||||
calendar: 'traditional',
|
||||
locale: 'th_TH',
|
||||
) }}
|
||||
|
||||
Timezone
|
||||
--------
|
||||
|
||||
@@ -118,6 +140,33 @@ The default timezone can also be set globally by calling ``setTimezone()``::
|
||||
$twig = new \Twig\Environment(...);
|
||||
$twig->addExtension(new IntlExtension());
|
||||
|
||||
.. _intl-date-format-defaults:
|
||||
|
||||
Configure Defaults
|
||||
------------------
|
||||
|
||||
You can configure the locale, formats and calendar once for all date and time
|
||||
filters by passing an ``IntlDateFormatter`` when registering the extension::
|
||||
|
||||
use Twig\Extra\Intl\IntlExtension;
|
||||
|
||||
$dateFormatter = new \IntlDateFormatter(
|
||||
locale: 'fr_FR',
|
||||
dateType: \IntlDateFormatter::LONG,
|
||||
timeType: \IntlDateFormatter::SHORT,
|
||||
calendar: \IntlDateFormatter::GREGORIAN,
|
||||
);
|
||||
|
||||
$twig->addExtension(new IntlExtension(
|
||||
dateFormatterPrototype: $dateFormatter,
|
||||
));
|
||||
|
||||
Arguments passed to a filter override these defaults. You can also configure a
|
||||
pattern with the ``pattern`` argument.
|
||||
|
||||
When using a dependency injection container, pass the formatter as the
|
||||
``$dateFormatterPrototype`` argument of the ``IntlExtension`` service.
|
||||
|
||||
Arguments
|
||||
---------
|
||||
|
||||
@@ -126,7 +175,7 @@ Arguments
|
||||
* ``timeFormat``: The time format
|
||||
* ``pattern``: A date time pattern
|
||||
* ``timezone``: The date timezone name
|
||||
* ``calendar``: The calendar ("gregorian" by default)
|
||||
* ``calendar``: The calendar
|
||||
|
||||
.. _ICU user guide: https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
|
||||
.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
``format_time``
|
||||
===============
|
||||
|
||||
The ``format_time`` filter formats a time. It behaves in the exact same way as
|
||||
the :doc:`format_datetime<format_datetime>` filter, but without the date.
|
||||
The ``format_time`` filter formats a time. It supports the same locale,
|
||||
timezone, calendar and format options as the
|
||||
:doc:`format_datetime<format_datetime>` filter, but without the date.
|
||||
|
||||
When no format or pattern is provided, Twig uses the
|
||||
:ref:`application default time format <intl-date-format-defaults>`, or
|
||||
``medium`` when none is configured. To use a custom pattern, pass it explicitly.
|
||||
The :ref:`application default pattern <intl-date-format-defaults>` is not used
|
||||
for time-only formatting.
|
||||
|
||||
.. note::
|
||||
|
||||
@@ -31,8 +38,8 @@ Arguments
|
||||
|
||||
* ``locale``: The locale code as defined in `RFC 5646`_
|
||||
* ``timeFormat``: The time format
|
||||
* ``pattern``: A date time pattern
|
||||
* ``pattern``: A time pattern
|
||||
* ``timezone``: The date timezone
|
||||
* ``calendar``: The calendar ("gregorian" by default)
|
||||
* ``calendar``: The calendar
|
||||
|
||||
.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
|
||||
|
||||
@@ -405,7 +405,7 @@ final class IntlExtension extends AbstractExtension
|
||||
* @param \DateTimeInterface|string|null $date A date or null to use the current time
|
||||
* @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
|
||||
*/
|
||||
public function formatDateTime(Environment $env, $date, ?string $dateFormat = null, ?string $timeFormat = null, string $pattern = '', $timezone = null, string $calendar = 'gregorian', ?string $locale = null): string
|
||||
public function formatDateTime(Environment $env, $date, ?string $dateFormat = null, ?string $timeFormat = null, string $pattern = '', $timezone = null, ?string $calendar = null, ?string $locale = null): string
|
||||
{
|
||||
$date = $env->getExtension(CoreExtension::class)->convertDate($date, $timezone);
|
||||
|
||||
@@ -428,7 +428,7 @@ final class IntlExtension extends AbstractExtension
|
||||
* @param \DateTimeInterface|string|null $date A date or null to use the current time
|
||||
* @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
|
||||
*/
|
||||
public function formatDate(Environment $env, $date, ?string $dateFormat = null, string $pattern = '', $timezone = null, string $calendar = 'gregorian', ?string $locale = null): string
|
||||
public function formatDate(Environment $env, $date, ?string $dateFormat = null, string $pattern = '', $timezone = null, ?string $calendar = null, ?string $locale = null): string
|
||||
{
|
||||
return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale);
|
||||
}
|
||||
@@ -437,7 +437,7 @@ final class IntlExtension extends AbstractExtension
|
||||
* @param \DateTimeInterface|string|null $date A date or null to use the current time
|
||||
* @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
|
||||
*/
|
||||
public function formatTime(Environment $env, $date, ?string $timeFormat = null, string $pattern = '', $timezone = null, string $calendar = 'gregorian', ?string $locale = null): string
|
||||
public function formatTime(Environment $env, $date, ?string $timeFormat = null, string $pattern = '', $timezone = null, ?string $calendar = null, ?string $locale = null): string
|
||||
{
|
||||
return $this->formatDateTime($env, $date, 'none', $timeFormat, $pattern, $timezone, $calendar, $locale);
|
||||
}
|
||||
@@ -454,7 +454,7 @@ final class IntlExtension extends AbstractExtension
|
||||
return $this->createListFormatter($locale, $type, $width)->format($strings);
|
||||
}
|
||||
|
||||
private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, ?\DateTimeZone $timezone, string $calendar): \IntlDateFormatter
|
||||
private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, ?\DateTimeZone $timezone, ?string $calendar): \IntlDateFormatter
|
||||
{
|
||||
$dateFormats = self::availableDateFormats();
|
||||
|
||||
@@ -473,16 +473,25 @@ final class IntlExtension extends AbstractExtension
|
||||
$locale = $locale ?: \Locale::getDefault();
|
||||
}
|
||||
|
||||
$calendar = 'gregorian' === $calendar ? \IntlDateFormatter::GREGORIAN : \IntlDateFormatter::TRADITIONAL;
|
||||
$calendar = null === $calendar ? null : ('gregorian' === $calendar ? \IntlDateFormatter::GREGORIAN : \IntlDateFormatter::TRADITIONAL);
|
||||
|
||||
$dateFormatValue = null === $dateFormat ? null : $dateFormats[$dateFormat];
|
||||
$timeFormatValue = null === $timeFormat ? null : self::TIME_FORMATS[$timeFormat];
|
||||
|
||||
if ($this->dateFormatterPrototype) {
|
||||
$dateFormatValue ??= $this->dateFormatterPrototype->getDateType();
|
||||
$timeFormatValue ??= $this->dateFormatterPrototype->getTimeType();
|
||||
if (null === $dateFormatValue && false !== $prototypeDateFormat = $this->dateFormatterPrototype->getDateType()) {
|
||||
$dateFormatValue = $prototypeDateFormat;
|
||||
}
|
||||
if (null === $timeFormatValue && false !== $prototypeTimeFormat = $this->dateFormatterPrototype->getTimeType()) {
|
||||
$timeFormatValue = $prototypeTimeFormat;
|
||||
}
|
||||
$timezone = $timezone ?: $this->dateFormatterPrototype->getTimeZone()->toDateTimeZone();
|
||||
$calendar = $calendar ?: $this->dateFormatterPrototype->getCalendar();
|
||||
if (null === $calendar) {
|
||||
$calendar = $this->dateFormatterPrototype->getCalendar();
|
||||
if (false === $calendar) {
|
||||
$calendar = $this->dateFormatterPrototype->getCalendarObject();
|
||||
}
|
||||
}
|
||||
// fall back to the prototype's pattern only when nothing else was given, else it would override the explicit date/time formats;
|
||||
// a pattern describes a full datetime rendering, so it cannot be honored by format_date/format_time, which pass 'none' for the other part
|
||||
if ('' === $pattern && null === $dateFormat && null === $timeFormat) {
|
||||
@@ -492,9 +501,15 @@ final class IntlExtension extends AbstractExtension
|
||||
|
||||
$dateFormatValue ??= \IntlDateFormatter::MEDIUM;
|
||||
$timeFormatValue ??= \IntlDateFormatter::MEDIUM;
|
||||
if (null === $calendar || false === $calendar) {
|
||||
$calendar = \IntlDateFormatter::GREGORIAN;
|
||||
}
|
||||
|
||||
if ($calendar instanceof \IntlCalendar) {
|
||||
return new \IntlDateFormatter($locale, $dateFormatValue, $timeFormatValue, $timezone, $calendar, $pattern);
|
||||
}
|
||||
|
||||
$timezoneName = $timezone ? $timezone->getName() : '(none)';
|
||||
|
||||
$hash = $locale.'|'.$dateFormatValue.'|'.$timeFormatValue.'|'.$timezoneName.'|'.$calendar.'|'.$pattern;
|
||||
|
||||
if (!isset($this->dateFormatters[$hash])) {
|
||||
|
||||
@@ -72,7 +72,7 @@ class IntlExtensionTest extends TestCase
|
||||
$this->assertContains(
|
||||
$ext->formatDateTime($env, new \DateTime('2020-02-20T13:37:00+00:00', new \DateTimeZone('Europe/Paris'))),
|
||||
[
|
||||
'jeudi 20 février 2020 à 13:37:00 heure normale d’Europe centrale',
|
||||
'jeudi 20 février 2020 à 13:37:00 heure normale d’Europe centrale', // codespell:ignore normale
|
||||
'jeudi 20 février 2020 à 13:37:00 temps universel coordonné',
|
||||
]
|
||||
);
|
||||
@@ -109,6 +109,76 @@ class IntlExtensionTest extends TestCase
|
||||
$this->assertSame('donderdag 20 februari 2020', $ext->formatDateTime($env, $date, 'full', 'none'));
|
||||
}
|
||||
|
||||
public function testFormatterCalendarPrecedence(): void
|
||||
{
|
||||
$env = new Environment(new ArrayLoader());
|
||||
$date = new \DateTime('2020-02-20T00:00:00+00:00');
|
||||
$gregorianProto = new \IntlDateFormatter('th_TH', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', \IntlDateFormatter::GREGORIAN);
|
||||
$traditionalProto = new \IntlDateFormatter('th_TH', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', \IntlDateFormatter::TRADITIONAL);
|
||||
$hebrewProto = new \IntlDateFormatter('en_US', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', \IntlCalendar::createInstance('UTC', 'en_US@calendar=hebrew'));
|
||||
$failedCalendarProto = new class('th_TH', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC') extends \IntlDateFormatter {
|
||||
public function getCalendar(): int|false
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getCalendarObject(): \IntlCalendar|false|null
|
||||
{
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame('2563', (new IntlExtension($gregorianProto))->formatDate($env, $date, pattern: 'yyyy', timezone: 'UTC', calendar: 'traditional', locale: 'th_TH'));
|
||||
$this->assertSame('2020', (new IntlExtension($traditionalProto))->formatDate($env, $date, pattern: 'yyyy', timezone: 'UTC', calendar: 'gregorian', locale: 'th_TH'));
|
||||
$this->assertSame('2563', (new IntlExtension($traditionalProto))->formatDate($env, $date, pattern: 'yyyy', timezone: 'UTC', locale: 'th_TH'));
|
||||
$this->assertSame('5780', (new IntlExtension($hebrewProto))->formatDate($env, $date, pattern: 'yyyy', timezone: 'UTC', locale: 'en_US'));
|
||||
$this->assertSame('2020', (new IntlExtension($failedCalendarProto))->formatDate($env, $date, pattern: 'yyyy', timezone: 'UTC', locale: 'th_TH'));
|
||||
$this->assertSame('2020', (new IntlExtension())->formatDate($env, $date, pattern: 'yyyy', timezone: 'UTC', locale: 'th_TH'));
|
||||
}
|
||||
|
||||
public function testFormatterObjectCalendarChangesAreApplied(): void
|
||||
{
|
||||
$env = new Environment(new ArrayLoader());
|
||||
$date = new \DateTime('2021-01-01T00:00:00+00:00');
|
||||
$calendar = \IntlCalendar::createInstance('UTC', 'en_US@calendar=gregorian');
|
||||
$calendar->setFirstDayOfWeek(\IntlCalendar::DOW_MONDAY);
|
||||
$calendar->setMinimalDaysInFirstWeek(4);
|
||||
$proto = new \IntlDateFormatter('en_US', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', $calendar);
|
||||
$ext = new IntlExtension($proto);
|
||||
|
||||
$this->assertSame('2020-53', $ext->formatDate($env, $date, pattern: 'Y-ww', timezone: 'UTC', locale: 'en_US'));
|
||||
|
||||
$calendar = \IntlCalendar::createInstance('UTC', 'en_US@calendar=gregorian');
|
||||
$calendar->setFirstDayOfWeek(\IntlCalendar::DOW_SUNDAY);
|
||||
$calendar->setMinimalDaysInFirstWeek(1);
|
||||
$proto->setCalendar($calendar);
|
||||
|
||||
$this->assertSame('2021-01', $ext->formatDate($env, $date, pattern: 'Y-ww', timezone: 'UTC', locale: 'en_US'));
|
||||
}
|
||||
|
||||
public function testFormatterProtoFormatFailuresFallBackToMedium(): void
|
||||
{
|
||||
$env = new Environment(new ArrayLoader());
|
||||
$date = new \DateTime('2020-02-20T00:00:00+00:00');
|
||||
$proto = new class('en_US', \IntlDateFormatter::FULL, \IntlDateFormatter::FULL, 'UTC') extends \IntlDateFormatter {
|
||||
public function getDateType(): int|false
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getTimeType(): int|false
|
||||
{
|
||||
return false;
|
||||
}
|
||||
};
|
||||
$ext = new IntlExtension($proto);
|
||||
$expectedDate = (new \IntlDateFormatter('en_US', \IntlDateFormatter::MEDIUM, \IntlDateFormatter::NONE, 'UTC', \IntlDateFormatter::GREGORIAN))->format($date);
|
||||
$expectedTime = (new \IntlDateFormatter('en_US', \IntlDateFormatter::NONE, \IntlDateFormatter::MEDIUM, 'UTC', \IntlDateFormatter::GREGORIAN))->format($date);
|
||||
|
||||
$this->assertSame($expectedDate, $ext->formatDate($env, $date, timezone: 'UTC', locale: 'en_US'));
|
||||
$this->assertSame($expectedTime, $ext->formatTime($env, $date, timezone: 'UTC', locale: 'en_US'));
|
||||
}
|
||||
|
||||
public function testFormatterProtoWithCustomPatternIsUsedByDefault(): void
|
||||
{
|
||||
$dateFormatterProto = new \IntlDateFormatter('nl_NL', \IntlDateFormatter::MEDIUM, \IntlDateFormatter::MEDIUM, new \DateTimeZone('Europe/Amsterdam'), \IntlDateFormatter::GREGORIAN, 'yyyy-MM-dd');
|
||||
|
||||
Reference in New Issue
Block a user