Honor date formatter prototype calendars

This commit is contained in:
Fabien Potencier
2026-08-26 20:44:15 +02:00
parent 885cbdb58f
commit 1de0bfceb4
6 changed files with 171 additions and 23 deletions
+1 -1
View File
@@ -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`
+11 -4
View File
@@ -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
+53 -4
View File
@@ -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
+11 -4
View File
@@ -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
+24 -9
View File
@@ -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])) {
+71 -1
View File
@@ -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 dEurope centrale',
'jeudi 20 février 2020 à 13:37:00 heure normale dEurope 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');