diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml
index d73a185..8d4edf3 100644
--- a/.github/workflows/unit-test.yml
+++ b/.github/workflows/unit-test.yml
@@ -21,10 +21,11 @@ jobs:
- "8.2"
- "8.3"
- "8.4"
+ - "8.5"
experimental:
- false
include:
- - php-version: "8.5"
+ - php-version: "8.6"
composer-options: "--ignore-platform-reqs"
experimental: true
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index d23fe89..3e5d146 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,12 +1,14 @@
-
-
-
- ./tests
-
-
-
-
- ./src
-
-
-
\ No newline at end of file
+
+
+
+
+ ./src
+
+
+
+
+ ./tests
+
+
+
diff --git a/src/DateTimeFormatter.php b/src/DateTimeFormatter.php
index a9deb24..157d3ca 100644
--- a/src/DateTimeFormatter.php
+++ b/src/DateTimeFormatter.php
@@ -2,7 +2,7 @@
namespace Sunkan\Dictus;
-final class DateTimeFormatter implements Formatter, MutableFormatter
+final class DateTimeFormatter implements FormatterDateTime, MutableFormatter
{
public function __construct(
private string $format,
@@ -17,4 +17,14 @@ public function setFormat(string $format): void
{
$this->format = $format;
}
+
+ public function formatTimestamp(string $format, \DateTimeImmutable $timestamp): string
+ {
+ return $timestamp->format($format);
+ }
+
+ public function formatDate(string $format, \DateTimeInterface $date): string
+ {
+ return $date->format($format);
+ }
}
diff --git a/src/Formatter.php b/src/Formatter.php
index 9dc5d1e..5649a54 100644
--- a/src/Formatter.php
+++ b/src/Formatter.php
@@ -5,4 +5,9 @@
interface Formatter
{
public function format(\DateTimeInterface $date): string;
+
+ /**
+ * @deprecated use FormatterDateTime::formatDate
+ */
+ public function formatTimestamp(string $format, \DateTimeImmutable $timestamp): string;
}
diff --git a/src/FormatterDateTime.php b/src/FormatterDateTime.php
new file mode 100644
index 0000000..3c3b04e
--- /dev/null
+++ b/src/FormatterDateTime.php
@@ -0,0 +1,8 @@
+ 'H:i',
'LTS' => 'H:i:s',
'L' => 'd/m/Y',
'LL' => 'j F Y',
- 'LLL' => 'j F H:i',
- 'LLLL' => 'l, j F Y H:i',
+ 'll' => 'j M Y',
+ 'LLL' => 'j F Y [at] H:i',
+ 'lll' => 'j M Y, H:i',
+ 'LLLL' => 'l, j F Y [at] H:i',
+ 'llll' => 'D, j M Y, H:i',
default => null,
};
}
public function formatChar(string $char, \DateTimeImmutable $dateTime): ?string
{
+ if ($char === 'M' && $dateTime->format('n') === '9') {
+ return 'Sept';
+ }
return null;
}
}
diff --git a/src/Locales/EnUs.php b/src/Locales/EnUs.php
new file mode 100644
index 0000000..84c3bac
--- /dev/null
+++ b/src/Locales/EnUs.php
@@ -0,0 +1,29 @@
+ 'g:i A',
+ 'LTS' => 'g:i:s A',
+ 'L' => 'n/j/y',
+ 'LL' => 'F j, Y',
+ 'll' => 'M j, Y',
+ 'LLL' => 'F j, Y [at] g:i A',
+ 'lll' => 'M j, Y, g:i A',
+ 'LLLL' => 'l, F j, Y [at] g:i A',
+ 'llll' => 'D, M j, Y, g:i A',
+ default => null,
+ };
+ }
+
+ public function formatChar(string $char, \DateTimeImmutable $dateTime): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Locales/IsIs.php b/src/Locales/IsIs.php
index 2c6cf39..711ea5e 100644
--- a/src/Locales/IsIs.php
+++ b/src/Locales/IsIs.php
@@ -7,13 +7,13 @@
final class IsIs implements LocaleFormat
{
private const DAYS_SHORT = [
- 1 => 'mán',
- 2 => 'þri',
- 3 => 'mið',
- 4 => 'fim',
- 5 => 'fös',
- 6 => 'lau',
- 7 => 'sun',
+ 1 => 'mán.',
+ 2 => 'þri.',
+ 3 => 'mið.',
+ 4 => 'fim.',
+ 5 => 'fös.',
+ 6 => 'lau.',
+ 7 => 'sun.',
];
private const DAYS_LONG = [
@@ -27,18 +27,18 @@ final class IsIs implements LocaleFormat
];
private const MONTHS_SHORT = [
- 1 => 'jan',
- 2 => 'feb',
- 3 => 'mar',
- 4 => 'apr',
+ 1 => 'jan.',
+ 2 => 'feb.',
+ 3 => 'mar.',
+ 4 => 'apr.',
5 => 'maí',
- 6 => 'jún',
- 7 => 'júl',
- 8 => 'ágú',
- 9 => 'sep',
- 10 => 'okt',
- 11 => 'nóv',
- 12 => 'des',
+ 6 => 'jún.',
+ 7 => 'júl.',
+ 8 => 'ágú.',
+ 9 => 'sep.',
+ 10 => 'okt.',
+ 11 => 'nóv.',
+ 12 => 'des.',
];
private const MONTHS_LONG = [
@@ -72,10 +72,13 @@ public function resolveFormat(string $format): ?string
return match($format) {
'LT' => 'H:i',
'LTS' => 'H:i:s',
- 'L' => 'd.m.Y',
+ 'L' => 'j.n.Y',
'LL' => 'j. F Y',
- 'LLL' => 'j. F [kl.] H:i',
- 'LLLL' => 'l j. F Y [kl.] H:i',
+ 'll' => 'j. M Y',
+ 'LLL' => 'j. F Y [kl.] H:i',
+ 'lll' => 'j. M Y, H:i',
+ 'LLLL' => 'l, j. F Y [kl.] H:i',
+ 'llll' => 'D j. M Y, H:i',
default => null,
};
}
diff --git a/src/Locales/SvSe.php b/src/Locales/SvSe.php
index 80391b1..c6dd9ce 100644
--- a/src/Locales/SvSe.php
+++ b/src/Locales/SvSe.php
@@ -7,53 +7,53 @@
final class SvSe implements LocaleFormat
{
private const MONTHS_SHORT = [
- 1 => 'Jan',
- 2 => 'Feb',
- 3 => 'Mar',
- 4 => 'Apr',
- 5 => 'Maj',
- 6 => 'Jun',
- 7 => 'Jul',
- 8 => 'Aug',
- 9 => 'Sep',
- 10 => 'Okt',
- 11 => 'Nov',
- 12 => 'Dec',
+ 1 => 'jan.',
+ 2 => 'feb.',
+ 3 => 'mars',
+ 4 => 'apr.',
+ 5 => 'maj',
+ 6 => 'juni',
+ 7 => 'juli',
+ 8 => 'aug.',
+ 9 => 'sep.',
+ 10 => 'okt.',
+ 11 => 'nov.',
+ 12 => 'dec.',
];
private const MONTHS_LONG = [
- 1 => 'Januari',
- 2 => 'Februari',
- 3 => 'Mars',
- 4 => 'April',
- 5 => 'Maj',
- 6 => 'Juni',
- 7 => 'Juli',
- 8 => 'Augusti',
- 9 => 'September',
- 10 => 'Oktober',
- 11 => 'November',
- 12 => 'December',
+ 1 => 'januari',
+ 2 => 'februari',
+ 3 => 'mars',
+ 4 => 'april',
+ 5 => 'maj',
+ 6 => 'juni',
+ 7 => 'juli',
+ 8 => 'augusti',
+ 9 => 'september',
+ 10 => 'oktober',
+ 11 => 'november',
+ 12 => 'december',
];
private const DAYS_SHORT = [
1 => 'mån',
2 => 'tis',
3 => 'ons',
- 4 => 'tor',
+ 4 => 'tors',
5 => 'fre',
6 => 'lör',
7 => 'sön',
];
private const DAYS_LONG = [
- 1 => 'Måndag',
- 2 => 'Tisdag',
- 3 => 'Onsdag',
- 4 => 'Torsdag',
- 5 => 'Fredag',
- 6 => 'Lördag',
- 7 => 'Söndag',
+ 1 => 'måndag',
+ 2 => 'tisdag',
+ 3 => 'onsdag',
+ 4 => 'torsdag',
+ 5 => 'fredag',
+ 6 => 'lördag',
+ 7 => 'söndag',
];
public function formatChar(string $char, \DateTimeImmutable $dateTime): ?string
@@ -72,10 +72,13 @@ public function resolveFormat(string $format): ?string
return match($format) {
'LT' => 'H:i',
'LTS' => 'H:i:s',
- 'L' => 'd.m.Y',
+ 'L' => 'Y-m-d',
'LL' => 'j F Y',
- 'LLL' => 'j F [kl.] H:i',
+ 'll' => 'j M Y',
+ 'LLL' => 'j F Y [kl.] H:i',
+ 'lll' => 'j M Y H:i',
'LLLL' => 'l j F Y [kl.] H:i',
+ 'llll' => 'D j M Y H:i',
default => null,
};
}
diff --git a/src/LocalizedDateTimeFormatter.php b/src/LocalizedDateTimeFormatter.php
index 962dc41..94d4cbe 100644
--- a/src/LocalizedDateTimeFormatter.php
+++ b/src/LocalizedDateTimeFormatter.php
@@ -2,8 +2,20 @@
namespace Sunkan\Dictus;
-final class LocalizedDateTimeFormatter implements LocalizedFormatter, MutableFormatter
+final class LocalizedDateTimeFormatter implements LocalizedFormatter, MutableFormatter, FormatterDateTime
{
+ private const LOCALIZED_SHORT_FORMATS = [
+ // Sorting this list correctly is important because of how we replace from it
+ 'LTS',
+ 'LT',
+ 'LLLL',
+ 'llll',
+ 'LLL',
+ 'lll',
+ 'LL',
+ 'll',
+ 'L',
+ ];
/** @var array */
private static array $localFormats = [];
@@ -34,11 +46,27 @@ public function setLocale(string $locale): void
public function format(\DateTimeInterface $date): string
{
- return $this->formatTimestamp($this->format, \DateTimeImmutable::createFromInterface($date), $this->locale);
+ return $this->formatDate($this->format, $date);
}
- public function formatTimestamp(string $format, \DateTimeImmutable $timestamp, string $locale): string
+ public function formatTimestamp(string $format, \DateTimeImmutable $timestamp): string
{
+ return $this->formatDate($format, $timestamp);
+ }
+
+ public function formatDate(string $format, \DateTimeInterface $date): string
+ {
+ $timestamp = \DateTimeImmutable::createFromInterface($date);
+ foreach (self::LOCALIZED_SHORT_FORMATS as $localizedFormat) {
+ if (!str_contains($format, $localizedFormat)) {
+ continue;
+ }
+ $tmpFormat = $this->localeFormat->resolveFormat($localizedFormat);
+ if ($tmpFormat !== null) {
+ $format = str_replace($localizedFormat, $tmpFormat, $format);
+ }
+ }
+
$result = '';
$length = mb_strlen($format);
$inEscaped = false;
@@ -70,25 +98,11 @@ public function formatTimestamp(string $format, \DateTimeImmutable $timestamp, s
continue;
}
$localResult = $this->localeFormat->formatChar($char, $timestamp);
- if ($localResult) {
+ if ($localResult) {
$result .= $localResult;
continue;
}
- $input = mb_substr($format, $i);
- if ($char === 'L' && preg_match('/^(LTS|LT|L{1,4})/', $input, $match)) {
- $code = $match[0];
- $newFormat = $this->localeFormat->resolveFormat($code);
- if ($newFormat) {
- $result .= $this->formatTimestamp($newFormat, $timestamp, $locale);
- }
- else {
- $result .= $code;
- }
- $i += mb_strlen($code) - 1;
- continue;
- }
-
$result .= $timestamp->format($char);
}
return $result;
diff --git a/src/LocalizedStrftimeFormatter.php b/src/LocalizedStrftimeFormatter.php
index ccdeb98..6598e63 100644
--- a/src/LocalizedStrftimeFormatter.php
+++ b/src/LocalizedStrftimeFormatter.php
@@ -5,7 +5,7 @@
use DateTimeImmutable;
use IntlDateFormatter;
-final class LocalizedStrftimeFormatter implements LocalizedFormatter, MutableFormatter
+final class LocalizedStrftimeFormatter implements LocalizedFormatter, MutableFormatter, FormatterDateTime
{
private const INTL_FORMATS = [
'%a' => 'EEE', // An abbreviated textual representation of the day Sun through Sat
@@ -23,9 +23,9 @@ public function __construct(
private string $format,
) {}
- public function setLocale(string $local): void
+ public function setLocale(string $locale): void
{
- $this->locale = $local;
+ $this->locale = $locale;
}
public function setFormat(string $format): void
@@ -39,6 +39,16 @@ public function format(\DateTimeInterface $date): string
return $this->strftime($this->format, $date, $this->locale);
}
+ public function formatTimestamp(string $format, \DateTimeImmutable $timestamp): string
+ {
+ return $this->strftime($format, $timestamp, $this->locale);
+ }
+
+ public function formatDate(string $format, \DateTimeInterface $date): string
+ {
+ return $this->strftime($format, DateTimeImmutable::createFromInterface($date), $this->locale);
+ }
+
private function strftime(string $format, DateTimeImmutable $timestamp, string $local): string
{
// remove trailing part not supported by ext-intl locale
diff --git a/tests/IntlComparisonTest.php b/tests/IntlComparisonTest.php
new file mode 100644
index 0000000..6ad26a5
--- /dev/null
+++ b/tests/IntlComparisonTest.php
@@ -0,0 +1,267 @@
+ [IntlDateFormatter::NONE, IntlDateFormatter::SHORT],
+ 'LTS' => [IntlDateFormatter::NONE, IntlDateFormatter::MEDIUM],
+ 'L' => [IntlDateFormatter::SHORT, IntlDateFormatter::NONE],
+ 'LL' => [IntlDateFormatter::LONG, IntlDateFormatter::NONE],
+ 'll' => [IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE],
+ 'LLL' => [IntlDateFormatter::LONG, IntlDateFormatter::SHORT],
+ 'lll' => [IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT],
+ 'LLLL' => [IntlDateFormatter::FULL, IntlDateFormatter::SHORT],
+ // This is not available via intl formats without setting a pattern
+ // 'llll' => [],
+ ];
+
+ /**
+ * @return array
+ */
+ public static function provideLocales(): array
+ {
+ return [
+ 'en' => ['en_US', new EnUs()],
+ 'uk' => ['en_GB', new EnGb()],
+ 'is' => ['is_IS', new IsIs()],
+ 'se' => ['sv_SE', new SvSe()],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideMonths(): array
+ {
+ $ret = [];
+ foreach (self::provideLocales() as $key => $args) {
+ for ($month = 1; $month <= 12; $month++) {
+ $args[2] = $month;
+ $ret["$key-$month"] = $args;
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideWeekdays(): array
+ {
+ $ret = [];
+ foreach (self::provideLocales() as $key => $args) {
+ for ($wd = 1; $wd <= 7; $wd++) {
+ $args[2] = $wd;
+ $ret["$key-$wd"] = $args;
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideFormats(): array
+ {
+ $ret = [];
+ foreach (self::provideLocales() as $key => $args) {
+ foreach (array_keys(self::INTL_FORMAT_MAP) as $k) {
+ $args[2] = $k;
+ $ret["$key-$k"] = $args;
+ }
+ }
+ return $ret;
+ }
+
+ public static function setUpBeforeClass(): void
+ {
+ parent::setUpBeforeClass();
+ self::checkIcuDataVersion();
+ }
+
+ /**
+ * @dataProvider provideMonths
+ */
+ public function testFullMonth(string $locale, LocaleFormat $localeFormat, int $month): void
+ {
+ LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat);
+ $formatter = new LocalizedDateTimeFormatter($locale, 'F');
+ $intlFormatter = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::SHORT, null, null, 'MMMM');
+
+ $m = $this->pad($month);
+ $date = new DateTimeImmutable("2023-$m-03 11:11:11");
+
+ $this->assertSame(
+ $intlFormatter->format($date),
+ $formatter->format($date),
+ );
+ }
+
+ /**
+ * @dataProvider provideMonths
+ */
+ public function testAbbrMonth(string $locale, LocaleFormat $localeFormat, int $month): void
+ {
+ LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat);
+ $formatter = new LocalizedDateTimeFormatter($locale, 'M');
+ $intlFormatter = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::SHORT, null, null, 'MMM');
+
+ $m = $this->pad($month);
+ $date = new DateTimeImmutable("2023-$m-03 11:11:11");
+
+ $this->assertSame(
+ $intlFormatter->format($date),
+ $formatter->format($date),
+ );
+ }
+
+ /**
+ * @dataProvider provideWeekdays
+ */
+ public function testFullWeekday(string $locale, LocaleFormat $localeFormat, int $weekday): void
+ {
+ LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat);
+ $formatter = new LocalizedDateTimeFormatter($locale, 'l');
+ $intlFormatter = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::SHORT, null, null, 'EEEE');
+
+ $w = $this->pad($weekday);
+ $date = new DateTimeImmutable("2025-12-$w 11:11:11");
+
+ $this->assertSame(
+ $intlFormatter->format($date),
+ $formatter->format($date),
+ );
+ }
+
+ /**
+ * @dataProvider provideWeekdays
+ */
+ public function testAbbrWeekday(string $locale, LocaleFormat $localeFormat, int $weekday): void
+ {
+ LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat);
+ $formatter = new LocalizedDateTimeFormatter($locale, 'D');
+ $intlFormatter = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::SHORT, null, null, 'EEE');
+
+ $w = $this->pad($weekday);
+ $date = new DateTimeImmutable("2025-12-$w 11:11:11");
+
+ $this->assertSame(
+ $intlFormatter->format($date),
+ $formatter->format($date),
+ );
+ }
+
+ /**
+ * @dataProvider provideFormats
+ */
+ public function testFormats(string $locale, LocaleFormat $localeFormat, string $format): void
+ {
+ LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat);
+ $formatter = new LocalizedDateTimeFormatter($locale, $format);
+ [$dateType, $timeType] = self::INTL_FORMAT_MAP[$format];
+
+ $intlFormatter = new IntlDateFormatter(
+ $locale,
+ $dateType,
+ $timeType,
+ );
+
+ foreach ($this->createDates() as $date) {
+ $this->assertSame(
+ $intlFormatter->format($date),
+ $formatter->format($date),
+ );
+ }
+ }
+
+ /**
+ * @dataProvider provideLocales
+ */
+ public function testSpecialLocalFormat(string $locale, LocaleFormat $localeFormat): void
+ {
+ LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat);
+ $formatter = new LocalizedDateTimeFormatter($locale, 'llll');
+
+ $intlFormatter = new IntlDateFormatter(
+ $locale,
+ IntlDateFormatter::MEDIUM,
+ IntlDateFormatter::SHORT,
+ );
+
+ $dates = $this->createDates();
+ foreach ($dates as $date) {
+ // This should just contain the same as "lll"
+ $this->assertStringContainsString(
+ (string)$intlFormatter->format($date),
+ $formatter->format($date),
+ );
+ }
+ $intlFormatter->setPattern('eee');
+ foreach ($dates as $date) {
+ $this->assertStringContainsString(
+ (string)$intlFormatter->format($date),
+ $formatter->format($date),
+ );
+ }
+ }
+
+ private static function checkIcuDataVersion(): void
+ {
+ try {
+ $reflector = new \ReflectionExtension('intl');
+ ob_start();
+ $reflector->info();
+ $output = strip_tags((string)ob_get_clean());
+ preg_match('/^ICU Data version (?:=>)?(.*)$/m', $output, $matches);
+ $icuDataVersion = trim($matches[1]);
+ } catch (\ReflectionException) {
+ $icuDataVersion = '';
+ }
+ if ((int)$icuDataVersion < 76) {
+ self::markTestSkipped(sprintf(
+ 'Intl comparison tests expect ICU Data version to be at least 76. Current is %s',
+ $icuDataVersion,
+ ));
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private function createDates(): array
+ {
+ $date = new DateTimeImmutable('2025-01-03 09:11:11');
+ $dates = [
+ $date,
+ $date->add(new \DateInterval('PT12H')),
+ ];
+
+ for ($i = 1; $i <= 7; $i++) {
+ $dates[] = $date = $date->add(new \DateInterval('P1D'));
+ }
+ for ($i = 1; $i <= 12; $i++) {
+ $dates[] = $date = $date->add(new \DateInterval('P1M'));
+ }
+ return $dates;
+ }
+
+ private function pad(int $month): string
+ {
+ return str_pad((string)$month, 2, '0', STR_PAD_LEFT);
+ }
+}
diff --git a/tests/LocalizedDateTimeFormatterTest.php b/tests/LocalizedDateTimeFormatterTest.php
index ddbbd1e..882352f 100644
--- a/tests/LocalizedDateTimeFormatterTest.php
+++ b/tests/LocalizedDateTimeFormatterTest.php
@@ -1,7 +1,6 @@
assertSame('mánudagur 21. ágúst kl. 16:26 Test', $formatter->format($date));
+ $this->assertSame('mánudagur 21. ágúst 2023 kl. 16:26 Test', $formatter->format($date));
}
public function testEnglishFormat(): void
@@ -23,6 +22,6 @@ public function testEnglishFormat(): void
$date = new DateTimeImmutable('2023-08-21 16:26:14');
- $this->assertSame('Monday 21 August 16:26 Test', $formatter->format($date));
+ $this->assertSame('Monday 21 August 2023 at 16:26 Test', $formatter->format($date));
}
}