From bb0fc4d373baba6f9f3371a958cafe4c9eca0a9c Mon Sep 17 00:00:00 2001
From: Benni Mack <benni@typo3.org>
Date: Tue, 14 Mar 2023 15:06:20 +0100
Subject: [PATCH] [FEATURE] Introduce php-intl based date&time formatting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A new stdWrap "formattedDate" feature is added,
which uses the ICU-based pattern formatting
see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax

In addition, the DateViewHelper (<f:format.date>)
now allows to set a `pattern` value,
which - if given - also uses the ICU-based pattern
formatting.

All of the features are based on the given Locale,
so no need for using the system-wide "setlocale"
functionality is needed anymore, as the tests show.

The ICU-based pattern allows for much more flexible
and fine-grained date formatting, such as

* Quarter of the year (QQQQ)
* Day of week in month (F)
* Milliseconds in day (A)

Resolves: #100187
Releases: main
Change-Id: I6f90a5cb73ea48cabf6b11662c8b28d7c21bca1a
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/78120
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Oliver Bartsch <bo@cedev.de>
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Oliver Bartsch <bo@cedev.de>
---
 .../Classes/Localization/DateFormatter.php    |  61 ++++++++
 ...-100187-ICU-basedDateAndTimeFormatting.rst |  97 +++++++++++++
 .../Unit/Localization/DateFormatterTest.php   | 132 ++++++++++++++++++
 .../ViewHelpers/Format/DateViewHelper.php     |  56 ++++++++
 .../ViewHelpers/Format/DateViewHelperTest.php |  30 ++++
 .../ContentObject/ContentObjectRenderer.php   |  31 +++-
 .../ContentObjectRendererTest.php             |  89 +++++++++++-
 7 files changed, 493 insertions(+), 3 deletions(-)
 create mode 100644 typo3/sysext/core/Classes/Localization/DateFormatter.php
 create mode 100644 typo3/sysext/core/Documentation/Changelog/12.3/Feature-100187-ICU-basedDateAndTimeFormatting.rst
 create mode 100644 typo3/sysext/core/Tests/Unit/Localization/DateFormatterTest.php

diff --git a/typo3/sysext/core/Classes/Localization/DateFormatter.php b/typo3/sysext/core/Classes/Localization/DateFormatter.php
new file mode 100644
index 000000000000..a48268e6b6cf
--- /dev/null
+++ b/typo3/sysext/core/Classes/Localization/DateFormatter.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Localization;
+
+use TYPO3\CMS\Core\Utility\MathUtility;
+
+/**
+ * Wrapper for dealing with ICU-based (php-intl) date formatting
+ * see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+ */
+class DateFormatter
+{
+    /**
+     * Formats any given input ($date) into a localized, formatted result
+     *
+     * @param mixed $date could be a DateTime object, a string or a number (Unix Timestamp)
+     * @param string|int $format the pattern, as defined by the ICU - see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+     * @param string|Locale $locale the locale to be used, e.g. "nl-NL"
+     * @return string the formatted output, such as "Tuesday at 12:40:20"
+     */
+    public function format(mixed $date, string|int $format, string|Locale $locale): string
+    {
+        $locale = (string)$locale;
+        if (is_int($format) || MathUtility::canBeInterpretedAsInteger($format)) {
+            $dateFormatter = new \IntlDateFormatter($locale, (int)$format, (int)$format);
+        } else {
+            $dateFormatter = match (strtoupper($format)) {
+                'FULL' => new \IntlDateFormatter($locale, \IntlDateFormatter::FULL, \IntlDateFormatter::FULL),
+                'FULLDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::FULL, \IntlDateFormatter::NONE),
+                'FULLTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::FULL),
+                'LONG' => new \IntlDateFormatter($locale, \IntlDateFormatter::LONG, \IntlDateFormatter::LONG),
+                'LONGDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::LONG, \IntlDateFormatter::NONE),
+                'LONGTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::LONG),
+                'MEDIUM' => new \IntlDateFormatter($locale, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::MEDIUM),
+                'MEDIUMDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::NONE),
+                'MEDIUMTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::MEDIUM),
+                'SHORT' => new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT),
+                'SHORTDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE),
+                'SHORTTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT),
+                // Use a custom pattern
+                default => new \IntlDateFormatter($locale, \IntlDateFormatter::FULL, \IntlDateFormatter::FULL, null, null, $format),
+            };
+        }
+        return $dateFormatter->format($date) ?: '';
+    }
+}
diff --git a/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100187-ICU-basedDateAndTimeFormatting.rst b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100187-ICU-basedDateAndTimeFormatting.rst
new file mode 100644
index 000000000000..96ecdaaa32be
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100187-ICU-basedDateAndTimeFormatting.rst
@@ -0,0 +1,97 @@
+.. include:: /Includes.rst.txt
+
+.. _feature-100187-1679001588:
+
+=====================================================
+Feature: #100187 - ICU-based date and time formatting
+=====================================================
+
+See :issue:`100187`
+
+Description
+===========
+
+TYPO3 now supports rendering date and time based on formats/patterns defined by
+the International Components for Unicode standard (ICU).
+
+TYPO3 previously only supported rendering of dates based on the PHP-native
+functions :php:`date()` and :php:`strftime()`.
+
+However, :php:`date()` can only format dates with english texts, such as
+"December" as non-localized values, the C-based :php:`strftime()` function works
+only with the locale defined in PHP and availability in the underlying operating
+system.
+
+In addition, ICU-based date and time formatting is much more flexible in
+rendering, as it ships with default patterns for date and time (namely
+`FULL`, `LONG`, `MEDIUM` and `SHORT`) which are based on the given locale.
+
+This means, that when the locale `en-US` is given, the short date is rendered
+as `mm/dd/yyyy` whereas `de-AT` uses the `dd.mm.yyyy` syntax automatically,
+without having to define a custom pattern just by using the SHORT default
+pattern.
+
+In addition, the patterns can be adjusted more fine-grained, and can easily
+deal with TimeZones for output when DateTime objects are handed in.
+
+TYPO3 also adds prepared custom patterns:
+
+* `FULLDATE` (like `FULL`, but only the date information)
+* `FULLTIME` (like `FULL`, but only the time information)
+* `LONGDATE` (like `LONG`, but only the date information)
+* `LONGTIME` (like `LONG`, but only the time information)
+* `MEDIUMDATE` (like `MEDIUM`, but only the date information)
+* `MEDIUMTIME` (like `MEDIUM`, but only the time information)
+* `SHORTDATE` (like `SHORT`, but only the date information)
+* `SHORTTIME` (like `SHORT`, but only the time information)
+
+See https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+for more information on the patterns.
+
+
+Impact
+======
+
+A new stdWrap feature called `formattedDate` is added, and the new formatting
+can also be used in Fluid's :html:`<f:format.date>` ViewHelper.
+
+The locale is typically fetched from the Site languages' locale (stdWrap or
+ViewHelper), or the backend users' language (in Backend context) for the
+ViewHelper usages.
+
+Examples for stdWrap:
+
+.. code-block:: typoscript
+
+    page.10 = TEXT
+    page.10.value = 1998-02-20 3:00:00
+    # see all available options https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+    page.10.formattedDate = FULL
+    # optional, if a different locale is wanted other than the Site Language's locale
+    page.10.formattedDate.locale = de-DE
+
+will result in "Freitag, 20. Februar 1998 um 03:00:00 Koordinierte Weltzeit".
+
+.. code-block:: typoscript
+
+    page.10 = TEXT
+    page.10.value = -5 days
+    page.10.formattedDate = FULL
+    page.10.formattedDate.locale = fr-FR
+
+will result in "jeudi 9 mars 2023 à 21:40:49 temps universel coordonné".
+
+Examples for Fluid `<f:format.date>` ViewHelper:
+
+.. code-block:: html
+
+    <f:format.date pattern="dd. MMMM yyyy" locale="de-DE">{date}</f:format.date>
+
+will result in "20. Februar 1998".
+
+As soon as the :html:`pattern` attribute is used, the :html:`format` attribute
+is disregarded.
+
+Both new ViewHelper arguments are optional.
+
+.. index:: Fluid, PHP-API, TypoScript, ext:core
diff --git a/typo3/sysext/core/Tests/Unit/Localization/DateFormatterTest.php b/typo3/sysext/core/Tests/Unit/Localization/DateFormatterTest.php
new file mode 100644
index 000000000000..a682f9e3efe9
--- /dev/null
+++ b/typo3/sysext/core/Tests/Unit/Localization/DateFormatterTest.php
@@ -0,0 +1,132 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Tests\Unit\Localization;
+
+use TYPO3\CMS\Core\Localization\DateFormatter;
+use TYPO3\CMS\Core\Localization\Locale;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class DateFormatterTest extends UnitTestCase
+{
+    protected function formatDateProvider(): \Generator
+    {
+        yield 'regular formatting - no locale' => [
+            '2023.02.02 AD at 13:05:00 UTC',
+            "yyyy.MM.dd G 'at' HH:mm:ss zzz",
+        ];
+        yield 'full - no locale' => [
+            'Thursday, February 2, 2023 at 13:05:00 Coordinated Universal Time',
+            'FULL',
+        ];
+        yield 'long - no locale' => [
+            'February 2, 2023 at 13:05:00 UTC',
+            'LONG',
+        ];
+        yield 'medium - no locale' => [
+            'Feb 2, 2023, 13:05:00',
+            'MEDIUM',
+        ];
+        yield 'medium with int - no locale' => [
+            'Feb 2, 2023, 13:05:00',
+            \IntlDateFormatter::MEDIUM,
+        ];
+        yield 'medium with int as string - no locale' => [
+            'Feb 2, 2023, 13:05:00',
+            (string)\IntlDateFormatter::MEDIUM,
+        ];
+        yield 'short - no locale' => [
+            '2/2/23, 13:05',
+            'SHORT',
+        ];
+        yield 'short in lowercase - no locale' => [
+            '2/2/23, 13:05',
+            'short',
+        ];
+        yield 'regular formatting - en-US locale' => [
+            '2023.02.02 AD at 13:05:00 UTC',
+            "yyyy.MM.dd G 'at' HH:mm:ss zzz",
+            'en-US',
+        ];
+        yield 'full - en-US locale' => [
+            'Thursday, February 2, 2023 at 1:05:00 PM Coordinated Universal Time',
+            'FULL',
+            'en-US',
+        ];
+        yield 'long - en-US locale' => [
+            'February 2, 2023 at 1:05:00 PM UTC',
+            'LONG',
+            'en-US',
+        ];
+        yield 'medium - en-US locale' => [
+            'Feb 2, 2023, 1:05:00 PM',
+            'MEDIUM',
+            'en-US',
+        ];
+        yield 'short - en-US locale' => [
+            '2/2/23, 1:05 PM',
+            'SHORT',
+            'en-US',
+        ];
+        yield 'regular formatting - german locale' => [
+            '2023.02.02 n. Chr. um 13:05:00 UTC',
+            "yyyy.MM.dd G 'um' HH:mm:ss zzz",
+            'de-DE',
+        ];
+        yield 'full - german locale' => [
+            'Donnerstag, 2. Februar 2023 um 13:05:00 Koordinierte Weltzeit',
+            'FULL',
+            'de-DE',
+        ];
+        yield 'long - german locale' => [
+            '2. Februar 2023 um 13:05:00 UTC',
+            'LONG',
+            'de-DE',
+        ];
+        yield 'medium - german locale' => [
+            '02.02.2023, 13:05:00',
+            'MEDIUM',
+            'de-DE',
+        ];
+        yield 'short - german locale' => [
+            '02.02.23, 13:05',
+            'SHORT',
+            'de-DE',
+        ];
+        yield 'custom date only - german locale' => [
+            '02. Februar 2023',
+            'dd. MMMM yyyy',
+            'de-DE',
+        ];
+        yield 'custom time only - german locale' => [
+            '13:05:00',
+            'HH:mm:ss',
+            new Locale('de'),
+        ];
+    }
+
+    /**
+     * @test
+     * @dataProvider formatDateProvider
+     */
+    public function formatFormatsCorrectly(string $expected, mixed $format, string|Locale|null $locale = 'C'): void
+    {
+        $input = new \DateTimeImmutable('2023-02-02 13:05:00');
+        $subject = new DateFormatter();
+        self::assertEquals($expected, $subject->format($input, $format, $locale));
+    }
+}
diff --git a/typo3/sysext/fluid/Classes/ViewHelpers/Format/DateViewHelper.php b/typo3/sysext/fluid/Classes/ViewHelpers/Format/DateViewHelper.php
index 0cce323a97ed..462baa0bbb3c 100644
--- a/typo3/sysext/fluid/Classes/ViewHelpers/Format/DateViewHelper.php
+++ b/typo3/sysext/fluid/Classes/ViewHelpers/Format/DateViewHelper.php
@@ -17,9 +17,15 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Fluid\ViewHelpers\Format;
 
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Http\ApplicationType;
+use TYPO3\CMS\Core\Localization\DateFormatter;
+use TYPO3\CMS\Core\Localization\Locale;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Core\Utility\MathUtility;
+use TYPO3\CMS\Fluid\Core\Rendering\RenderingContext;
 use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
 use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
 use TYPO3Fluid\Fluid\Core\ViewHelper\Exception;
@@ -84,6 +90,26 @@ use TYPO3Fluid\Fluid\Core\ViewHelper\Traits\CompileWithContentArgumentAndRenderS
  * ``13. Dezember 1980``
  * Depending on the current date and defined locale. In the example you see the 1980-12-13 in a german locale.
  *
+ * Localized dates using ICU-based date and time formatting
+ * --------------------------------------------------------
+ *
+ * ::
+ *
+ *    <f:format.date pattern="dd. MMMM yyyy" locale="de-DE">{dateObject}</f:format.date>
+ *
+ * ``13. Dezember 1980``
+ * Depending on the current date. In the example you see the 1980-12-13 in a german locale.
+ *
+ * Localized dates using default formatting patterns
+ * -------------------------------------------------
+ *
+ * ::
+ *
+ *    <f:format.date pattern="FULL" locale="fr-FR">{dateObject}</f:format.date>
+ *
+ * ``jeudi 9 mars 2023 à 21:40:49 temps universel coordonné``
+ * Depending on the current date and operating system setting. In the example you see the 2023-03-09 in a french locale.
+ *
  * Inline notation
  * ---------------
  *
@@ -119,6 +145,8 @@ final class DateViewHelper extends AbstractViewHelper
     {
         $this->registerArgument('date', 'mixed', 'Either an object implementing DateTimeInterface or a string that is accepted by DateTime constructor');
         $this->registerArgument('format', 'string', 'Format String which is taken to format the Date/Time', false, '');
+        $this->registerArgument('pattern', 'string', 'Format date based on unicode ICO format pattern given see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax. If both "pattern" and "format" arguments are given, pattern will be used.');
+        $this->registerArgument('locale', 'string', 'A locale format such as "nl-NL" to format the date in a specific locale, if none given, uses the current locale of the current request. Only works when pattern argument is given');
         $this->registerArgument('base', 'mixed', 'A base time (an object implementing DateTimeInterface or a string) used if $date is a relative date specification. Defaults to current time.');
     }
 
@@ -128,6 +156,7 @@ final class DateViewHelper extends AbstractViewHelper
     public static function renderStatic(array $arguments, \Closure $renderChildrenClosure, RenderingContextInterface $renderingContext): string
     {
         $format = $arguments['format'] ?? '';
+        $pattern = $arguments['pattern'] ?? null;
         $base = $arguments['base'] ?? GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp');
         if (is_string($base)) {
             $base = trim($base);
@@ -161,6 +190,10 @@ final class DateViewHelper extends AbstractViewHelper
             }
         }
 
+        if ($pattern !== null) {
+            $locale = $arguments['locale'] ?? self::resolveLocale($renderingContext);
+            return (new DateFormatter())->format($date, $pattern, $locale);
+        }
         if (str_contains($format, '%')) {
             // @todo Replace deprecated strftime in php 8.1. Suppress warning in v11.
             return @strftime($format, (int)$date->format('U'));
@@ -175,4 +208,27 @@ final class DateViewHelper extends AbstractViewHelper
     {
         return 'date';
     }
+
+    private static function resolveLocale(RenderingContextInterface $renderingContext): Locale
+    {
+        $request = null;
+        if ($renderingContext instanceof RenderingContext) {
+            $request = $renderingContext->getRequest();
+        } elseif (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface) {
+            $request = $GLOBALS['TYPO3_REQUEST'];
+        }
+        if ($request && ApplicationType::fromRequest($request)->isFrontend()) {
+            // Frontend application
+            $siteLanguage = $request->getAttribute('language');
+
+            // Get values from site language
+            if ($siteLanguage !== null) {
+                return $siteLanguage->getLocale();
+            }
+        } elseif (($GLOBALS['BE_USER'] ?? null) instanceof BackendUserAuthentication
+            && !empty($GLOBALS['BE_USER']->user['lang'])) {
+            return new Locale($GLOBALS['BE_USER']->user['lang']);
+        }
+        return new Locale();
+    }
 }
diff --git a/typo3/sysext/fluid/Tests/Functional/ViewHelpers/Format/DateViewHelperTest.php b/typo3/sysext/fluid/Tests/Functional/ViewHelpers/Format/DateViewHelperTest.php
index 1ee00b015148..b991553f35f1 100644
--- a/typo3/sysext/fluid/Tests/Functional/ViewHelpers/Format/DateViewHelperTest.php
+++ b/typo3/sysext/fluid/Tests/Functional/ViewHelpers/Format/DateViewHelperTest.php
@@ -244,4 +244,34 @@ class DateViewHelperTest extends FunctionalTestCase
         $context->getTemplatePaths()->setTemplateSource('<f:format.date date="' . $date . '" format="Y-m-d H:i"/>');
         self::assertEquals($expected, (new TemplateView($context))->render());
     }
+
+    public function viewHelperUsesIcuBasedPatternDataProvider(): \Generator
+    {
+        yield 'default value in english' => [
+            '10:55:36 on a Tuesday',
+            'HH:mm:ss \'on a\' cccc',
+            'en-US',
+        ];
+        yield 'quarter of the year in french' => [
+            '4e trimestre',
+            'QQQQ',
+            'fr',
+        ];
+        yield 'quarter of the year - no locale' => [
+            '4th quarter of 2000',
+            'QQQQ \'of\' yyyy',
+        ];
+    }
+
+    /**
+     * @test
+     * @dataProvider viewHelperUsesIcuBasedPatternDataProvider
+     */
+    public function viewHelperUsesIcuBasedPattern(string $expected, string|int $pattern, ?string $locale = null): void
+    {
+        $date = '03/Oct/2000:14:55:36 +0400';
+        $context = $this->get(RenderingContextFactory::class)->create();
+        $context->getTemplatePaths()->setTemplateSource('<f:format.date date="' . $date . '" pattern="' . $pattern . '" locale="' . $locale . '"/>');
+        self::assertEquals($expected, (new TemplateView($context))->render());
+    }
 }
diff --git a/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php b/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php
index bcbd9fca1535..8d64ec2f4dec 100644
--- a/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php
+++ b/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php
@@ -39,6 +39,7 @@ use TYPO3\CMS\Core\Html\SanitizerBuilderFactory;
 use TYPO3\CMS\Core\Html\SanitizerInitiator;
 use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
 use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
+use TYPO3\CMS\Core\Localization\DateFormatter;
 use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
 use TYPO3\CMS\Core\Localization\Locales;
 use TYPO3\CMS\Core\Log\LogManager;
@@ -102,7 +103,7 @@ class ContentObjectRenderer implements LoggerAwareInterface
      * @see stdWrap()
      * @var string[]
      */
-    public $stdWrapOrder = [
+    public array $stdWrapOrder = [
         'stdWrapPreProcess' => 'hook',
         // this is a placeholder for the first Hook
         'cacheRead' => 'hook',
@@ -177,6 +178,8 @@ class ContentObjectRenderer implements LoggerAwareInterface
         'strtotime.' => 'array',
         'strftime' => 'strftimeconf',
         'strftime.' => 'array',
+        'formattedDate' => 'formattedDateconf',
+        'formattedDate.' => 'array',
         'age' => 'boolean',
         'age.' => 'array',
         'case' => 'case',
@@ -1900,6 +1903,32 @@ class ContentObjectRenderer implements LoggerAwareInterface
         return strtotime($content, $GLOBALS['EXEC_TIME']);
     }
 
+    /**
+     * php-intl dateformatted
+     * Will return a timestamp based on configuration given according to PHP-intl DateFormatter->format()
+     * see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+     *
+     * @param string $content Input value undergoing processing in this function.
+     * @param array $conf stdWrap properties for formattedDate.
+     * @return string The processed input value
+     */
+    public function stdWrap_formattedDate(string $content, array $conf): string
+    {
+        $pattern = $conf['formattedDate'] ?? 'LONG';
+        $locale = $conf['formattedDate.']['locale'] ?? $this->getTypoScriptFrontendController()->getLanguage()->getLocale();
+
+        if ($content === '' || $content === '0') {
+            $content = $this->getTypoScriptFrontendController()->getContext()->getAspect('date')->getDateTime();
+        } else {
+            // format this to a timestamp now
+            $content = strtotime((MathUtility::canBeInterpretedAsInteger($content) ? '@' : '') . $content);
+            if ($content === false) {
+                $content = $this->getTypoScriptFrontendController()->getContext()->getAspect('date')->getDateTime();
+            }
+        }
+        return (new DateFormatter())->format($content, $pattern, $locale);
+    }
+
     /**
      * age
      * Will return the age of a given timestamp based on configuration given by stdWrap properties
diff --git a/typo3/sysext/frontend/Tests/Unit/ContentObject/ContentObjectRendererTest.php b/typo3/sysext/frontend/Tests/Unit/ContentObject/ContentObjectRendererTest.php
index 68315697e6d1..ad6d4e42635b 100644
--- a/typo3/sysext/frontend/Tests/Unit/ContentObject/ContentObjectRendererTest.php
+++ b/typo3/sysext/frontend/Tests/Unit/ContentObject/ContentObjectRendererTest.php
@@ -30,6 +30,7 @@ use TYPO3\CMS\Core\Cache\Frontend\NullFrontend;
 use TYPO3\CMS\Core\Configuration\Features;
 use TYPO3\CMS\Core\Configuration\SiteConfiguration;
 use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\DateTimeAspect;
 use TYPO3\CMS\Core\Context\UserAspect;
 use TYPO3\CMS\Core\Context\WorkspaceAspect;
 use TYPO3\CMS\Core\Core\ApplicationContext;
@@ -3699,7 +3700,7 @@ class ContentObjectRendererTest extends UnitTestCase
             }
         }
         self::assertSame(1, $notCallable);
-        self::assertSame(81, $callable);
+        self::assertSame(82, $callable);
     }
 
     /**
@@ -3750,7 +3751,7 @@ class ContentObjectRendererTest extends UnitTestCase
             }
         }
         self::assertSame($expectExceptions, $exceptions);
-        self::assertSame(81, $count);
+        self::assertSame(82, $count);
     }
 
     /***************************************************************************
@@ -4631,6 +4632,90 @@ class ContentObjectRendererTest extends UnitTestCase
         );
     }
 
+    protected function stdWrap_formattedDateProvider(): \Generator
+    {
+        yield 'regular formatting - no locale' => [
+            '2023.02.02 AD at 13:05:00 UTC',
+            "yyyy.MM.dd G 'at' HH:mm:ss zzz",
+        ];
+        yield 'full - no locale' => [
+            'Thursday, February 2, 2023 at 13:05:00 Coordinated Universal Time',
+            'FULL',
+        ];
+        yield 'long - no locale' => [
+            'February 2, 2023 at 13:05:00 UTC',
+            'LONG',
+        ];
+        yield 'medium - no locale' => [
+            'Feb 2, 2023, 13:05:00',
+            'MEDIUM',
+        ];
+        yield 'medium with int - no locale' => [
+            'Feb 2, 2023, 13:05:00',
+            \IntlDateFormatter::MEDIUM,
+        ];
+        yield 'short - no locale' => [
+            '2/2/23, 13:05',
+            'SHORT',
+        ];
+        yield 'regular formatting - german locale' => [
+            '2023.02.02 n. Chr. um 13:05:00 UTC',
+            "yyyy.MM.dd G 'um' HH:mm:ss zzz",
+            'de-DE',
+        ];
+        yield 'full - german locale' => [
+            'Donnerstag, 2. Februar 2023 um 13:05:00 Koordinierte Weltzeit',
+            'FULL',
+            'de-DE',
+        ];
+        yield 'long - german locale' => [
+            '2. Februar 2023 um 13:05:00 UTC',
+            'LONG',
+            'de-DE',
+        ];
+        yield 'medium - german locale' => [
+            '02.02.2023, 13:05:00',
+            'MEDIUM',
+            'de-DE',
+        ];
+        yield 'short - german locale' => [
+            '02.02.23, 13:05',
+            'SHORT',
+            'de-DE',
+        ];
+        yield 'custom date only - german locale' => [
+            '02. Februar 2023',
+            'dd. MMMM yyyy',
+            'de-DE',
+        ];
+        yield 'custom time only - german locale' => [
+            '13:05:00',
+            'HH:mm:ss',
+            'de-DE',
+        ];
+        yield 'given date and time - german locale' => [
+            'Freitag, 20. Februar 1998 um 03:00:00 Koordinierte Weltzeit',
+            'FULL',
+            'de-DE',
+            '1998-02-20 3:00:00',
+        ];
+    }
+
+    /**
+     * @test
+     * @dataProvider stdWrap_formattedDateProvider
+     */
+    public function stdWrap_formattedDate(string $expected, mixed $pattern, string $locale = null, string $givenDate = null): void
+    {
+        $this->frontendControllerMock->getContext()->setAspect('date', new DateTimeAspect(new \DateTimeImmutable('2023-02-02 13:05:00')));
+        $subject = new ContentObjectRenderer($this->frontendControllerMock);
+        $conf = ['formattedDate' => $pattern];
+        if ($locale !== null) {
+            $conf['formattedDate.']['locale'] = $locale;
+        }
+        self::assertEquals($expected, $subject->stdWrap_formattedDate((string)$givenDate, $conf));
+    }
+
     /**
      * Data provider for stdWrap_csConv
      *
-- 
GitLab