diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1c9c6103d8..83e43bff18d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: - '8.2' - '8.3' - '8.4' + - '8.5' steps: - name: "Checkout code" @@ -68,6 +69,7 @@ jobs: - '8.2' - '8.3' - '8.4' + - '8.5' steps: - name: "Checkout code" @@ -119,6 +121,7 @@ jobs: - '8.2' - '8.3' - '8.4' + - '8.5' extension: - 'cache-extra' - 'cssinliner-extra' @@ -183,7 +186,7 @@ jobs: strategy: matrix: php-version: - - '8.4' + - '8.5' steps: - name: "Checkout code" diff --git a/CHANGELOG b/CHANGELOG index c930ffc2be0..3f734071a63 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +# 3.29.0 (2026-XX-XX) + + * Add a `format_list` filter to `IntlExtension` to format a list of strings using PHP 8.5's `IntlListFormatter` + # 3.28.1 (2026-XX-XX) * Fix duplicated macro argument names triggering a PHP fatal error instead of a `SyntaxError` diff --git a/doc/filters/format_list.rst b/doc/filters/format_list.rst new file mode 100644 index 00000000000..fee8c921cf5 --- /dev/null +++ b/doc/filters/format_list.rst @@ -0,0 +1,88 @@ +``format_list`` +================ + +.. versionadded:: 3.29 + + The ``html_attr_merge`` filter was added in Twig 3.29. + +.. note:: + + The ``format_list`` filter requires the ``IntlListFormatter`` class, which is available since PHP 8.5. + + +The ``format_list`` filter formats a list of strings as a single, locale-aware string: + +.. code-block:: twig + + {# en: Apple, Banana, and Cherry #} + {{ ['Apple', 'Banana', 'Cherry']|format_list }} + +The list of supported types: + +* ``and``: Formats the list as a conjunction (default):: + + {# en: Apple, Banana, and Cherry #} + {{ ['Apple', 'Banana', 'Cherry']|format_list(type: 'and', locale: 'en') }} + +* ``or``: Formats the list as a disjunction:: + + {# en: Apple, Banana, or Cherry #} + {{ ['Apple', 'Banana', 'Cherry']|format_list(type: 'or', locale: 'en') }} + +* ``units``: Formats the list as a compound unit, without implying a conjunction or disjunction:: + + {# en: Apple, Banana, Cherry #} + {{ ['Apple', 'Banana', 'Cherry']|format_list(type: 'units', locale: 'en') }} + +The list of supported widths: + +* ``wide``: The full-length form (default):: + + {# en: Apple, Banana, and Cherry #} + {{ ['Apple', 'Banana', 'Cherry']|format_list(width: 'wide', locale: 'en') }} + +* ``short``: A shorter form:: + + {# en: Apple, Banana, & Cherry #} + {{ ['Apple', 'Banana', 'Cherry']|format_list(width: 'short', locale: 'en') }} + +* ``narrow``: The most compact form:: + + {# en: Apple, Banana, Cherry #} + {{ ['Apple', 'Banana', 'Cherry']|format_list(width: 'narrow', locale: 'en') }} + +By default, the filter uses the current locale. You can pass it explicitly:: + + {# fr: Apple, Banana et Cherry #} + {{ ['Apple', 'Banana', 'Cherry']|format_list(locale: 'fr') }} + + +.. note:: + + The ``format_list`` filter is part of the ``IntlExtension`` which is not installed by default. Install it first: + + .. code-block:: sh + + $ composer require twig/intl-extra + + Then, on Symfony projects, install the ``twig/extra-bundle``: + + .. code-block:: sh + + $ composer require twig/extra-bundle + + Otherwise, add the extension explicitly on the Twig environment:: + + use Twig\Extra\Intl\IntlExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new IntlExtension()); + +Arguments +--------- + +* ``type``: The type of the list output +* ``width``: The width of the list output +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/index.rst b/doc/filters/index.rst index 9827cdd5a7b..4b7b9df0886 100644 --- a/doc/filters/index.rst +++ b/doc/filters/index.rst @@ -24,6 +24,7 @@ Filters format_currency format_date format_datetime + format_list format_number format_time html_attr_merge diff --git a/extra/intl-extra/IntlExtension.php b/extra/intl-extra/IntlExtension.php index 3c6401349b3..cc740f6563c 100644 --- a/extra/intl-extra/IntlExtension.php +++ b/extra/intl-extra/IntlExtension.php @@ -56,6 +56,40 @@ private static function availableDateFormats(): array return $formats; } + private static function availableListTypes(): array + { + static $types = null; + + if (null !== $types) { + return $types; + } + + $types = [ + 'and' => \IntlListFormatter::TYPE_AND, + 'or' => \IntlListFormatter::TYPE_OR, + 'units' => \IntlListFormatter::TYPE_UNITS, + ]; + + return $types; + } + + private static function availableListWidths(): array + { + static $widths = null; + + if (null !== $widths) { + return $widths; + } + + $widths = [ + 'wide' => \IntlListFormatter::WIDTH_WIDE, + 'short' => \IntlListFormatter::WIDTH_SHORT, + 'narrow' => \IntlListFormatter::WIDTH_NARROW, + ]; + + return $widths; + } + private const TIME_FORMATS = [ 'none' => \IntlDateFormatter::NONE, 'short' => \IntlDateFormatter::SHORT, @@ -149,6 +183,7 @@ private static function availableDateFormats(): array private $dateFormatters = []; private $numberFormatters = []; + private $listFormatters = []; private $dateFormatterPrototype; private $numberFormatterPrototype; @@ -176,6 +211,7 @@ public function getFilters(): array new TwigFilter('format_datetime', [$this, 'formatDateTime'], ['needs_environment' => true]), new TwigFilter('format_date', [$this, 'formatDate'], ['needs_environment' => true]), new TwigFilter('format_time', [$this, 'formatTime'], ['needs_environment' => true]), + new TwigFilter('format_list', [$this, 'formatList']), ]; } @@ -406,6 +442,18 @@ public function formatTime(Environment $env, $date, ?string $timeFormat = 'mediu return $this->formatDateTime($env, $date, 'none', $timeFormat, $pattern, $timezone, $calendar, $locale); } + /** + * @param array $strings A list of items to be joined into a formatted list + */ + public function formatList(array $strings, string $type = 'and', string $width = 'wide', ?string $locale = null): string + { + if (!class_exists('IntlListFormatter')) { + throw new RuntimeError('The "format_list" filter requires the "IntlListFormatter" class, which is available since PHP 8.5.'); + } + + return $this->createListFormatter($locale, $type, $width)->format($strings); + } + private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, ?\DateTimeZone $timezone, string $calendar): \IntlDateFormatter { $dateFormats = self::availableDateFormats(); @@ -530,4 +578,37 @@ private function createNumberFormatter(?string $locale, string $style, array $at return $this->numberFormatters[$hash]; } + + private function createListFormatter(?string $locale, string $type, string $width): \IntlListFormatter + { + $listTypes = self::availableListTypes(); + + if (!isset($listTypes[$type])) { + throw new RuntimeError(\sprintf('The list type "%s" does not exist, known types are: "%s".', $type, implode('", "', array_keys($listTypes)))); + } + + $listWidths = self::availableListWidths(); + + if (!isset($listWidths[$width])) { + throw new RuntimeError(\sprintf('The list width "%s" does not exist, known widths are: "%s".', $width, implode('", "', array_keys($listWidths)))); + } + + if (null === $locale) { + $locale = \Locale::getDefault(); + } + + $listTypeValue = $listTypes[$type]; + $listWidthValue = $listWidths[$width]; + + $hash = $locale.'|'.$listTypeValue.'|'.$listWidthValue; + + if (!isset($this->listFormatters[$hash])) { + if (\count($this->listFormatters) >= self::MAX_CACHED_FORMATTERS) { + array_shift($this->listFormatters); + } + $this->listFormatters[$hash] = new \IntlListFormatter($locale, $listTypeValue, $listWidthValue); + } + + return $this->listFormatters[$hash]; + } } diff --git a/extra/intl-extra/README.md b/extra/intl-extra/README.md index ec169878bba..7ce50899073 100644 --- a/extra/intl-extra/README.md +++ b/extra/intl-extra/README.md @@ -4,7 +4,7 @@ Twig Intl Extension This package is a Twig extension that provides the following: * [`country_name`][1] filter: returns the country name given its two-letter/five-letter code; - * [`currency_name`][2] filter: returns the currency name given its three-letter code; +* [`currency_name`][2] filter: returns the currency name given its three-letter code; * [`currency_symbol`][3] filter: returns the currency symbol given its three-letter code; * [`language_name`][4] filter: returns the language name given its two-letter/five-letter code; * [`locale_name`][5] filter: returns the language name given its two-letter/five-letter code; @@ -15,6 +15,7 @@ This package is a Twig extension that provides the following: * [`format_datetime`][10] filter: formats a date time; * [`format_date`][11] filter: formats a date; * [`format_time`][12] filter: formats a time. + * [`format_list`][13] filter: formats a list of strings as a single, locale-aware string; [1]: https://twig.symfony.com/country_name [2]: https://twig.symfony.com/currency_name @@ -28,3 +29,4 @@ This package is a Twig extension that provides the following: [10]: https://twig.symfony.com/format_datetime [11]: https://twig.symfony.com/format_date [12]: https://twig.symfony.com/format_time +[13]: https://twig.symfony.com/format_list diff --git a/extra/intl-extra/Tests/Fixtures/format_list.test b/extra/intl-extra/Tests/Fixtures/format_list.test new file mode 100644 index 00000000000..57cb063409c --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/format_list.test @@ -0,0 +1,10 @@ +--TEST-- +"format_list" filter +--CONDITION-- +PHP_VERSION_ID < 80500 +--TEMPLATE-- +{{ ['Alice', 'Bob', 'Carol']|format_list() }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\RuntimeError: The "format_list" filter requires the "IntlListFormatter" class, which is available since PHP 8.5 in "index.twig" at line 2. diff --git a/extra/intl-extra/Tests/Fixtures/format_list_php85.test b/extra/intl-extra/Tests/Fixtures/format_list_php85.test new file mode 100644 index 00000000000..5277178ad5e --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/format_list_php85.test @@ -0,0 +1,34 @@ +--TEST-- +"format_list" filter +--CONDITION-- +PHP_VERSION_ID >= 80500 +--TEMPLATE-- +{{ ['Alice', 'Bob', 'Carol']|format_list() }} +{{ ['Alice', 'Bob', 'Carol']|format_list(width='short')|raw }} +{{ ['Alice', 'Bob', 'Carol']|format_list('or') }} +{{ ['Alice', 'Bob', 'Carol']|format_list(locale='fr') }} +{{ ['Alice', 'Bob', 'Carol']|format_list('and', 'short', locale='fr') }} +{{ ['Alice', 'Bob', 'Carol']|format_list('and', 'narrow', locale='fr') }} +{{ ['Alice', 'Bob', 'Carol']|format_list('or', 'wide', locale='fr') }} +{{ ['Alice', 'Bob', 'Carol']|format_list('or', 'short', locale='fr') }} +{{ ['Alice', 'Bob', 'Carol']|format_list('or', 'narrow', locale='fr') }} +{{ ['Alice', 'Bob', 'Carol']|format_list('units', 'wide', locale='fr') }} +{{ ['Alice', 'Bob', 'Carol']|format_list('units', 'short', locale='fr') }} +{{ ['Alice', 'Bob', 'Carol']|format_list('units', 'narrow', locale='fr') }} +{{ ['Alice', 'Bob', 'Carol']|format_list('units', 'narrow', locale='de') }} +--DATA-- +return []; +--EXPECT-- +Alice, Bob, and Carol +Alice, Bob, & Carol +Alice, Bob, or Carol +Alice, Bob et Carol +Alice, Bob et Carol +Alice, Bob, Carol +Alice, Bob ou Carol +Alice, Bob ou Carol +Alice, Bob ou Carol +Alice, Bob et Carol +Alice, Bob et Carol +Alice Bob Carol +Alice, Bob und Carol