diff --git a/Jint.Tests/Runtime/EngineTests.cs b/Jint.Tests/Runtime/EngineTests.cs
index f56c5cd7da..eaa0589a5a 100644
--- a/Jint.Tests/Runtime/EngineTests.cs
+++ b/Jint.Tests/Runtime/EngineTests.cs
@@ -1850,9 +1850,10 @@ public void DateToStringMethodsShouldUseCurrentTimeZoneAndCulture()
equal('Mon Jun 01 2015 05:00:00 GMT-0700 (Pacific Standard Time)', d.toString());
equal('Mon Jun 01 2015', d.toDateString());
equal('05:00:00 GMT-0700 (Pacific Standard Time)', d.toTimeString());
- equal('lundi 1 juin 2015 05:00:00', d.toLocaleString());
- equal('lundi 1 juin 2015', d.toLocaleDateString());
- equal('05:00:00', d.toLocaleTimeString());
+ // ECMA-402 compliant: numeric defaults used when no options specified
+ equal('1/6/2015 5:00:00', d.toLocaleString());
+ equal('1/6/2015', d.toLocaleDateString());
+ equal('5:00:00', d.toLocaleTimeString());
");
}
diff --git a/Jint/Native/Array/ArrayPrototype.cs b/Jint/Native/Array/ArrayPrototype.cs
index 21c8372301..a53600f2b6 100644
--- a/Jint/Native/Array/ArrayPrototype.cs
+++ b/Jint/Native/Array/ArrayPrototype.cs
@@ -1330,6 +1330,11 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
return JsString.Empty;
}
+ // Per ECMA-402, always pass locales and options to element's toLocaleString
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+ var invokeArgs = new[] { locales, options };
+
using var r = new ValueStringBuilder();
for (uint k = 0; k < len; k++)
{
@@ -1339,7 +1344,7 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
}
if (array.TryGetValue(k, out var nextElement) && !nextElement.IsNullOrUndefined())
{
- var s = TypeConverter.ToString(Invoke(nextElement, "toLocaleString", []));
+ var s = TypeConverter.ToString(Invoke(nextElement, "toLocaleString", invokeArgs));
r.Append(s);
}
}
diff --git a/Jint/Native/Date/DatePrototype.cs b/Jint/Native/Date/DatePrototype.cs
index 1b62c1b40c..10cd969f9d 100644
--- a/Jint/Native/Date/DatePrototype.cs
+++ b/Jint/Native/Date/DatePrototype.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
+using Jint.Native.Intl;
using Jint.Native.Object;
using Jint.Native.Symbol;
using Jint.Runtime;
@@ -211,6 +212,7 @@ private JsValue ToTimeString(JsValue thisObject, JsCallArguments arguments)
///
/// https://tc39.es/ecma262/#sec-date.prototype.tolocalestring
+ /// https://tc39.es/ecma402/#sup-date.prototype.tolocalestring
///
private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
{
@@ -221,11 +223,37 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
return "Invalid Date";
}
- return ToLocalTime(dateInstance).ToString("F", Engine.Options.Culture);
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per ECMA-402 ToDateTimeOptions("any", "all"):
+ // If no date/time options are specified, use default numeric components
+ var optionsObj = IntlUtilities.CoerceOptionsToObject(Engine, options);
+ var needDefaults = NeedDateTimeDefaults(optionsObj, checkDate: true, checkTime: true);
+
+ if (needDefaults)
+ {
+ // Add default date and time components per spec
+ // Use null prototype per ToDateTimeOptions step 2: ObjectCreate(options)
+ var newOptions = ObjectInstance.OrdinaryObjectCreate(Engine, null);
+ CopyOptions(optionsObj, newOptions);
+ newOptions.Set("year", "numeric");
+ newOptions.Set("month", "numeric");
+ newOptions.Set("day", "numeric");
+ newOptions.Set("hour", "numeric");
+ newOptions.Set("minute", "numeric");
+ newOptions.Set("second", "numeric");
+ options = newOptions;
+ }
+
+ // Use Intl.DateTimeFormat for locale-aware formatting
+ var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat);
+ return dateTimeFormat.Format(ToLocalTime(dateInstance));
}
///
/// https://tc39.es/ecma262/#sec-date.prototype.tolocaledatestring
+ /// https://tc39.es/ecma402/#sup-date.prototype.tolocaledatestring
///
private JsValue ToLocaleDateString(JsValue thisObject, JsCallArguments arguments)
{
@@ -236,11 +264,34 @@ private JsValue ToLocaleDateString(JsValue thisObject, JsCallArguments arguments
return "Invalid Date";
}
- return ToLocalTime(dateInstance).ToString("D", Engine.Options.Culture);
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per ECMA-402 ToDateTimeOptions("date", "date"):
+ // If no date options are specified, use default numeric date components
+ var optionsObj = IntlUtilities.CoerceOptionsToObject(Engine, options);
+ var needDefaults = NeedDateTimeDefaults(optionsObj, checkDate: true, checkTime: false);
+
+ if (needDefaults)
+ {
+ // Add default date components per spec
+ // Use null prototype per ToDateTimeOptions step 2: ObjectCreate(options)
+ var newOptions = ObjectInstance.OrdinaryObjectCreate(Engine, null);
+ CopyOptions(optionsObj, newOptions);
+ newOptions.Set("year", "numeric");
+ newOptions.Set("month", "numeric");
+ newOptions.Set("day", "numeric");
+ options = newOptions;
+ }
+
+ // Use Intl.DateTimeFormat for locale-aware formatting
+ var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat);
+ return dateTimeFormat.Format(ToLocalTime(dateInstance));
}
///
/// https://tc39.es/ecma262/#sec-date.prototype.tolocaletimestring
+ /// https://tc39.es/ecma402/#sup-date.prototype.tolocaletimestring
///
private JsValue ToLocaleTimeString(JsValue thisObject, JsCallArguments arguments)
{
@@ -251,7 +302,96 @@ private JsValue ToLocaleTimeString(JsValue thisObject, JsCallArguments arguments
return "Invalid Date";
}
- return ToLocalTime(dateInstance).ToString("T", Engine.Options.Culture);
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per ECMA-402 ToDateTimeOptions("time", "time"):
+ // If no time options are specified, use default numeric time components
+ var optionsObj = IntlUtilities.CoerceOptionsToObject(Engine, options);
+ var needDefaults = NeedDateTimeDefaults(optionsObj, checkDate: false, checkTime: true);
+
+ if (needDefaults)
+ {
+ // Add default time components per spec
+ // Use null prototype per ToDateTimeOptions step 2: ObjectCreate(options)
+ var newOptions = ObjectInstance.OrdinaryObjectCreate(Engine, null);
+ CopyOptions(optionsObj, newOptions);
+ newOptions.Set("hour", "numeric");
+ newOptions.Set("minute", "numeric");
+ newOptions.Set("second", "numeric");
+ options = newOptions;
+ }
+
+ // Use Intl.DateTimeFormat for locale-aware formatting
+ var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat);
+ return dateTimeFormat.Format(ToLocalTime(dateInstance));
+ }
+
+ ///
+ /// Checks if default date/time options should be applied per ECMA-402 ToDateTimeOptions.
+ /// Returns true if no relevant options are specified and dateStyle/timeStyle are not present.
+ ///
+ private static bool NeedDateTimeDefaults(ObjectInstance options, bool checkDate, bool checkTime)
+ {
+ // If dateStyle or timeStyle is present, don't add defaults
+ if (!options.Get("dateStyle").IsUndefined() || !options.Get("timeStyle").IsUndefined())
+ {
+ return false;
+ }
+
+ // Check date-related properties
+ if (checkDate)
+ {
+ if (!options.Get("weekday").IsUndefined() ||
+ !options.Get("year").IsUndefined() ||
+ !options.Get("month").IsUndefined() ||
+ !options.Get("day").IsUndefined() ||
+ !options.Get("era").IsUndefined())
+ {
+ return false;
+ }
+ }
+
+ // Check time-related properties
+ if (checkTime)
+ {
+ if (!options.Get("dayPeriod").IsUndefined() ||
+ !options.Get("hour").IsUndefined() ||
+ !options.Get("minute").IsUndefined() ||
+ !options.Get("second").IsUndefined() ||
+ !options.Get("fractionalSecondDigits").IsUndefined())
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Copies all defined options from source to target, preserving user-specified values.
+ ///
+ private static void CopyOptions(ObjectInstance source, ObjectInstance target)
+ {
+ // Copy all options that should be preserved (general + date/time components)
+ var optionsToCopy = new[]
+ {
+ // General options
+ "localeMatcher", "formatMatcher", "calendar", "numberingSystem", "timeZone", "hourCycle", "hour12",
+ "dateStyle", "timeStyle",
+ // Date components
+ "weekday", "era", "year", "month", "day",
+ // Time components
+ "dayPeriod", "hour", "minute", "second", "fractionalSecondDigits"
+ };
+ foreach (var option in optionsToCopy)
+ {
+ var value = source.Get(option);
+ if (!value.IsUndefined())
+ {
+ target.Set(option, value);
+ }
+ }
}
private JsValue GetTime(JsValue thisObject, JsCallArguments arguments)
diff --git a/Jint/Native/Intl/ChineseCalendarHelper.cs b/Jint/Native/Intl/ChineseCalendarHelper.cs
new file mode 100644
index 0000000000..fc8e6b07a5
--- /dev/null
+++ b/Jint/Native/Intl/ChineseCalendarHelper.cs
@@ -0,0 +1,192 @@
+using System.Globalization;
+
+namespace Jint.Native.Intl;
+
+///
+/// Helper class for Chinese and Dangi (Korean) calendar operations.
+/// Provides conversion from Gregorian dates to Chinese/Dangi calendar dates
+/// and computes the sexagenary cycle (干支 Gānzhī) year names.
+///
+internal static class ChineseCalendarHelper
+{
+ // Lazy initialization to avoid startup overhead when Chinese calendar is not used
+ private static ChineseLunisolarCalendar? _chineseCalendar;
+ private static KoreanLunisolarCalendar? _koreanCalendar;
+
+ private static ChineseLunisolarCalendar ChineseCalendar =>
+ _chineseCalendar ??= new ChineseLunisolarCalendar();
+
+ private static KoreanLunisolarCalendar KoreanCalendar =>
+ _koreanCalendar ??= new KoreanLunisolarCalendar();
+
+ ///
+ /// The 10 Heavenly Stems (天干 Tiāngān) in Chinese characters.
+ /// Used as part of the 60-year sexagenary cycle.
+ ///
+ private static readonly string[] HeavenlyStems =
+ [
+ "甲", // jiǎ
+ "乙", // yǐ
+ "丙", // bǐng
+ "丁", // dīng
+ "戊", // wù
+ "己", // jǐ
+ "庚", // gēng
+ "辛", // xīn
+ "壬", // rén
+ "癸" // guǐ
+ ];
+
+ ///
+ /// The 12 Earthly Branches (地支 Dìzhī) in Chinese characters.
+ /// Used as part of the 60-year sexagenary cycle.
+ ///
+ private static readonly string[] EarthlyBranches =
+ [
+ "子", // zǐ (Rat)
+ "丑", // chǒu (Ox)
+ "寅", // yín (Tiger)
+ "卯", // mǎo (Rabbit)
+ "辰", // chén (Dragon)
+ "巳", // sì (Snake)
+ "午", // wǔ (Horse)
+ "未", // wèi (Goat)
+ "申", // shēn (Monkey)
+ "酉", // yǒu (Rooster)
+ "戌", // xū (Dog)
+ "亥" // hài (Pig)
+ ];
+
+ ///
+ /// Gets Chinese calendar date information for a given DateTime.
+ ///
+ /// The Gregorian date to convert.
+ /// Chinese calendar date information including related year, year name, month, and day.
+ public static ChineseCalendarDate GetChineseDate(DateTime dateTime)
+ {
+ return GetLunisolarDate(dateTime, ChineseCalendar, isDangi: false);
+ }
+
+ ///
+ /// Gets Dangi (Korean lunisolar) calendar date information for a given DateTime.
+ /// The Dangi calendar is essentially the same as the Chinese calendar.
+ ///
+ /// The Gregorian date to convert.
+ /// Dangi calendar date information including related year, year name, month, and day.
+ public static ChineseCalendarDate GetDangiDate(DateTime dateTime)
+ {
+ return GetLunisolarDate(dateTime, KoreanCalendar, isDangi: true);
+ }
+
+ private static ChineseCalendarDate GetLunisolarDate(DateTime dateTime, EastAsianLunisolarCalendar calendar, bool isDangi)
+ {
+ // Clamp to supported range
+ var minDate = calendar.MinSupportedDateTime;
+ var maxDate = calendar.MaxSupportedDateTime;
+
+ if (dateTime < minDate)
+ {
+ dateTime = minDate;
+ }
+ else if (dateTime > maxDate)
+ {
+ dateTime = maxDate;
+ }
+
+ var year = calendar.GetYear(dateTime);
+ var month = calendar.GetMonth(dateTime);
+ var day = calendar.GetDayOfMonth(dateTime);
+
+ // Check if this is a leap month
+ var leapMonth = calendar.GetLeapMonth(year);
+ var isLeapMonth = leapMonth > 0 && month == leapMonth;
+
+ // Adjust month number for display (leap month has same number as previous month)
+ var displayMonth = month;
+ if (leapMonth > 0 && month >= leapMonth)
+ {
+ // If we're in or past the leap month, adjust the month number
+ displayMonth = month - 1;
+ if (month == leapMonth)
+ {
+ isLeapMonth = true;
+ }
+ }
+
+ // The year from both ChineseLunisolarCalendar and KoreanLunisolarCalendar
+ // is already the Gregorian-aligned "related year" (the Gregorian year that mostly
+ // contains this lunar year). No offset adjustment is needed for either calendar.
+ var relatedYear = year;
+
+ // Get the sexagenary cycle year name (干支)
+ var sexagenaryYear = calendar.GetSexagenaryYear(dateTime);
+ var yearName = GetSexagenaryYearName(sexagenaryYear);
+
+ return new ChineseCalendarDate(relatedYear, yearName, displayMonth, day, isLeapMonth);
+ }
+
+ ///
+ /// Gets the sexagenary cycle year name (干支 Gānzhī) for a given sexagenary year number.
+ ///
+ /// The sexagenary year number (1-60).
+ /// The two-character Chinese year name.
+ private static string GetSexagenaryYearName(int sexagenaryYear)
+ {
+ // Sexagenary year is 1-60, need to convert to 0-59 for array indexing
+ var index = sexagenaryYear - 1;
+
+ // The sexagenary cycle combines 10 Heavenly Stems with 12 Earthly Branches
+ var stemIndex = index % 10;
+ var branchIndex = index % 12;
+
+ return HeavenlyStems[stemIndex] + EarthlyBranches[branchIndex];
+ }
+
+ ///
+ /// Represents a date in the Chinese or Dangi lunisolar calendar.
+ ///
+ internal readonly struct ChineseCalendarDate
+ {
+ ///
+ /// Creates a new Chinese calendar date.
+ ///
+ public ChineseCalendarDate(int relatedYear, string yearName, int month, int day, bool isLeapMonth)
+ {
+ RelatedYear = relatedYear;
+ YearName = yearName;
+ Month = month;
+ Day = day;
+ IsLeapMonth = isLeapMonth;
+ }
+
+ ///
+ /// The "related year" - the Gregorian year that mostly overlaps with this Chinese calendar year.
+ /// For dates before Chinese New Year, this is the previous Gregorian year.
+ ///
+ public int RelatedYear { get; }
+
+ ///
+ /// The Chinese sexagenary cycle name (干支 Gānzhī) for the year.
+ /// Example: "己亥" (jǐ hài) for the year 2019.
+ ///
+ public string YearName { get; }
+
+ ///
+ /// The month number in the Chinese calendar (1-12).
+ /// Note: Leap months are indicated separately by IsLeapMonth.
+ ///
+ public int Month { get; }
+
+ ///
+ /// The day of the month in the Chinese calendar.
+ ///
+ public int Day { get; }
+
+ ///
+ /// Whether this date falls in a leap month.
+ /// In lunisolar calendars, a leap month is an intercalary month
+ /// inserted to keep the calendar aligned with the solar year.
+ ///
+ public bool IsLeapMonth { get; }
+ }
+}
diff --git a/Jint/Native/Intl/DateTimeFormatConstructor.cs b/Jint/Native/Intl/DateTimeFormatConstructor.cs
index cc7f2c7915..0e9cfd64d9 100644
--- a/Jint/Native/Intl/DateTimeFormatConstructor.cs
+++ b/Jint/Native/Intl/DateTimeFormatConstructor.cs
@@ -1,7 +1,9 @@
+using System.Globalization;
using Jint.Native.Function;
using Jint.Native.Object;
using Jint.Runtime;
using Jint.Runtime.Descriptors;
+using Jint.Runtime.Interop;
namespace Jint.Native.Intl;
@@ -11,6 +13,29 @@ namespace Jint.Native.Intl;
internal sealed class DateTimeFormatConstructor : Constructor
{
private static readonly JsString _functionName = new("DateTimeFormat");
+ private static readonly HashSet LocaleMatcherValues = ["lookup", "best fit"];
+ private static readonly HashSet FormatMatcherValues = ["basic", "best fit"];
+ private static readonly HashSet WeekdayValues = ["long", "short", "narrow"];
+ private static readonly HashSet EraValues = ["long", "short", "narrow"];
+ private static readonly HashSet YearValues = ["numeric", "2-digit"];
+ private static readonly HashSet MonthValues = ["numeric", "2-digit", "long", "short", "narrow"];
+ private static readonly HashSet DayValues = ["numeric", "2-digit"];
+ private static readonly HashSet DayPeriodValues = ["long", "short", "narrow"];
+ private static readonly HashSet HourValues = ["numeric", "2-digit"];
+ private static readonly HashSet MinuteValues = ["numeric", "2-digit"];
+ private static readonly HashSet SecondValues = ["numeric", "2-digit"];
+ private static readonly HashSet TimeZoneNameValues = ["long", "short", "shortOffset", "longOffset", "shortGeneric", "longGeneric"];
+ private static readonly HashSet HourCycleValues = ["h11", "h12", "h23", "h24"];
+ private static readonly HashSet DateStyleValues = ["full", "long", "medium", "short"];
+ private static readonly HashSet TimeStyleValues = ["full", "long", "medium", "short"];
+
+ // Supported calendars per ECMA-402 and Unicode CLDR
+ private static readonly HashSet SupportedCalendars =
+ [
+ "buddhist", "chinese", "coptic", "dangi", "ethioaa", "ethiopic",
+ "gregory", "hebrew", "indian", "islamic", "islamic-civil", "islamic-rgsa",
+ "islamic-tbla", "islamic-umalqura", "iso8601", "japanese", "persian", "roc"
+ ];
public DateTimeFormatConstructor(
Engine engine,
@@ -24,32 +49,720 @@ public DateTimeFormatConstructor(
_prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden);
}
+ protected override void Initialize()
+ {
+ const PropertyFlag PropertyFlags = PropertyFlag.Configurable | PropertyFlag.Writable;
+ var properties = new PropertyDictionary(1, checkExistingKeys: false)
+ {
+ ["supportedLocalesOf"] = new(new ClrFunction(Engine, "supportedLocalesOf", SupportedLocalesOf, 1, PropertyFlag.Configurable), PropertyFlags)
+ };
+ SetProperties(properties);
+ }
+
public DateTimeFormatPrototype PrototypeObject { get; }
+ ///
+ /// Called when Intl.DateTimeFormat is invoked without `new`.
+ ///
+ protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
+ {
+ return Construct(arguments, this);
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.datetimeformat
+ ///
public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget)
{
var locales = arguments.At(0);
var options = arguments.At(1);
- if (newTarget.IsUndefined())
+ // Get options object
+ var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options);
+
+ // Step 5: Get localeMatcher option first
+ var localeMatcher = GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, "best fit") ?? "best fit";
+
+ // Resolve locale (uses pre-read localeMatcher)
+ var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales);
+ var availableLocales = IntlUtilities.GetAvailableLocales();
+ var resolved = IntlUtilities.ResolveLocale(_engine, availableLocales, requestedLocales, localeMatcher, []);
+ var resolvedLocale = resolved.Locale;
+
+ // Extract unicode extension values from the requested locale
+ var requestedLocale = requestedLocales.Count > 0 ? requestedLocales[0] : "";
+ var extCalendar = ExtractUnicodeExtensionFromLocale(requestedLocale, "ca");
+ var extHourCycle = ExtractUnicodeExtensionFromLocale(requestedLocale, "hc");
+ var extNumberingSystem = ExtractUnicodeExtensionFromLocale(requestedLocale, "nu");
+
+ // Track which extensions should be preserved in the resolved locale
+ var preserveCalendarExt = false;
+ var preserveHourCycleExt = false;
+ var preserveNumberingSystemExt = false;
+
+ // Step 7: Get calendar option (between localeMatcher and hour12)
+ var calendarOption = GetUnicodeExtensionOption(optionsObj, "calendar");
+ string? calendar;
+
+ if (calendarOption != null)
+ {
+ // Option provided - validate and canonicalize
+ var canonicalizedOption = CanonicalizeCalendar(calendarOption);
+ if (IsValidCalendar(canonicalizedOption))
+ {
+ // Valid option - it overrides extension
+ calendar = canonicalizedOption;
+ // If option matches extension, preserve the extension
+ preserveCalendarExt = extCalendar != null && string.Equals(calendar, CanonicalizeCalendar(extCalendar), StringComparison.Ordinal);
+ }
+ else if (extCalendar != null && IsValidCalendar(CanonicalizeCalendar(extCalendar)))
+ {
+ // Invalid option, but valid extension - use extension
+ calendar = CanonicalizeCalendar(extCalendar);
+ preserveCalendarExt = true;
+ }
+ else
+ {
+ // Both invalid - use default
+ calendar = null;
+ }
+ }
+ else if (extCalendar != null)
+ {
+ // No option, check if extension is valid
+ var canonicalizedExt = CanonicalizeCalendar(extCalendar);
+ if (IsValidCalendar(canonicalizedExt))
+ {
+ calendar = canonicalizedExt;
+ preserveCalendarExt = true;
+ }
+ else
+ {
+ calendar = null;
+ }
+ }
+ else
+ {
+ calendar = null;
+ }
+
+ // Step 10: Get numberingSystem option
+ var numberingSystemOption = optionsObj.Get("numberingSystem");
+ string? numberingSystem;
+ var supported = _engine.Options.Intl.CldrProvider.GetSupportedNumberingSystems();
+
+ if (!numberingSystemOption.IsUndefined())
+ {
+ var nsOptionStr = TypeConverter.ToString(numberingSystemOption);
+ if (!IntlUtilities.IsValidUnicodeExtensionValue(nsOptionStr))
+ {
+ Throw.RangeError(_realm, $"Invalid value '{nsOptionStr}' for option 'numberingSystem'");
+ }
+ // Validate against supported numbering systems
+ string? validatedOption = null;
+ foreach (var ns in supported)
+ {
+ if (string.Equals(ns, nsOptionStr, StringComparison.OrdinalIgnoreCase))
+ {
+ validatedOption = ns;
+ break;
+ }
+ }
+
+ if (validatedOption != null)
+ {
+ // Valid option - it overrides extension
+ numberingSystem = validatedOption;
+ // If option matches extension, preserve the extension
+ preserveNumberingSystemExt = extNumberingSystem != null &&
+ string.Equals(numberingSystem, extNumberingSystem, StringComparison.OrdinalIgnoreCase);
+ }
+ else if (extNumberingSystem != null)
+ {
+ // Invalid option, check if extension is valid
+ string? validatedExt = null;
+ foreach (var ns in supported)
+ {
+ if (string.Equals(ns, extNumberingSystem, StringComparison.OrdinalIgnoreCase))
+ {
+ validatedExt = ns;
+ break;
+ }
+ }
+ if (validatedExt != null)
+ {
+ numberingSystem = validatedExt;
+ preserveNumberingSystemExt = true;
+ }
+ else
+ {
+ // Both invalid - use default
+ numberingSystem = null;
+ }
+ }
+ else
+ {
+ // Invalid option and no extension - use default
+ numberingSystem = null;
+ }
+ }
+ else if (extNumberingSystem != null)
+ {
+ // No option, use extension value if supported
+ numberingSystem = null;
+ foreach (var ns in supported)
+ {
+ if (string.Equals(ns, extNumberingSystem, StringComparison.OrdinalIgnoreCase))
+ {
+ numberingSystem = ns;
+ preserveNumberingSystemExt = true;
+ break;
+ }
+ }
+ }
+ else
+ {
+ numberingSystem = null;
+ }
+
+ // Step 13: Get hour12 option
+ var hour12Value = optionsObj.Get("hour12");
+ bool? hour12 = null;
+ if (!hour12Value.IsUndefined())
+ {
+ hour12 = TypeConverter.ToBoolean(hour12Value);
+ }
+
+ // Step 14: Get hourCycle option
+ var hourCycleOption = GetStringOption(optionsObj, "hourCycle", HourCycleValues, null);
+ string? hourCycle;
+
+ if (hourCycleOption != null || hour12.HasValue)
+ {
+ // Option or hour12 provided - they override extension
+ if (hour12.HasValue)
+ {
+ if (hour12.Value)
+ {
+ var lang = resolvedLocale.Split('-')[0].ToLowerInvariant();
+ hourCycle = string.Equals(lang, "ja", StringComparison.Ordinal) ? "h11" : "h12";
+ }
+ else
+ {
+ hourCycle = "h23";
+ }
+ }
+ else
+ {
+ hourCycle = hourCycleOption;
+ }
+ // If result matches extension, preserve the extension
+ preserveHourCycleExt = extHourCycle != null && string.Equals(hourCycle, extHourCycle, StringComparison.Ordinal);
+ }
+ else if (extHourCycle != null && HourCycleValues.Contains(extHourCycle))
+ {
+ // No option, use extension value and preserve it
+ hourCycle = extHourCycle;
+ preserveHourCycleExt = true;
+ }
+ else
+ {
+ hourCycle = null;
+ }
+
+ // Build the resolved locale with only preserved extensions
+ resolvedLocale = BuildResolvedLocale(resolvedLocale, requestedLocale,
+ preserveCalendarExt ? extCalendar : null,
+ preserveHourCycleExt ? extHourCycle : null,
+ preserveNumberingSystemExt ? extNumberingSystem : null);
+
+ // Step 29: Get timeZone option
+ var timeZone = GetTimeZoneOption(optionsObj);
+
+ // Step 36: Get component options in order (per Table 7 of ECMA-402)
+ // Order: weekday, era, year, month, day, dayPeriod, hour, minute, second, fractionalSecondDigits, timeZoneName
+ var weekday = GetStringOption(optionsObj, "weekday", WeekdayValues, null);
+ var era = GetStringOption(optionsObj, "era", EraValues, null);
+ var year = GetStringOption(optionsObj, "year", YearValues, null);
+ var month = GetStringOption(optionsObj, "month", MonthValues, null);
+ var day = GetStringOption(optionsObj, "day", DayValues, null);
+ var dayPeriod = GetStringOption(optionsObj, "dayPeriod", DayPeriodValues, null);
+ var hour = GetStringOption(optionsObj, "hour", HourValues, null);
+ var minute = GetStringOption(optionsObj, "minute", MinuteValues, null);
+ var second = GetStringOption(optionsObj, "second", SecondValues, null);
+ var fractionalSecondDigits = GetNumberOption(optionsObj, "fractionalSecondDigits", 1, 3, null);
+ var timeZoneName = GetStringOption(optionsObj, "timeZoneName", TimeZoneNameValues, null);
+
+ // Step 37: Get formatMatcher option
+ GetStringOption(optionsObj, "formatMatcher", FormatMatcherValues, null);
+
+ // Date/time style options
+ var dateStyle = GetStringOption(optionsObj, "dateStyle", DateStyleValues, null);
+ var timeStyle = GetStringOption(optionsObj, "timeStyle", TimeStyleValues, null);
+
+ // If dateStyle or timeStyle is specified, component options must NOT be specified
+ if (dateStyle != null || timeStyle != null)
+ {
+ if (weekday != null || era != null || year != null || month != null ||
+ day != null || dayPeriod != null || hour != null || minute != null ||
+ second != null || fractionalSecondDigits != null || timeZoneName != null)
+ {
+ Throw.TypeError(_realm, "Can't set date/time component options when dateStyle or timeStyle is used");
+ }
+ }
+ else
+ {
+ // If no date/time component options specified, use default date format
+ // Note: dayPeriod, fractionalSecondDigits, and timeZoneName don't prevent defaults
+ if (weekday == null && era == null && year == null && month == null &&
+ day == null && dayPeriod == null && hour == null && minute == null && second == null)
+ {
+ year = "numeric";
+ month = "numeric";
+ day = "numeric";
+ }
+ }
+
+ // Get DateTimeFormatInfo for the locale
+ var culture = IntlUtilities.GetCultureInfo(resolvedLocale) ?? CultureInfo.InvariantCulture;
+ var dateTimeFormatInfo = (DateTimeFormatInfo) culture.DateTimeFormat.Clone();
+
+ // Get prototype from newTarget (for cross-realm construction)
+ var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.DateTimeFormat.PrototypeObject);
+
+ return new JsDateTimeFormat(
+ _engine,
+ proto,
+ resolvedLocale,
+ calendar,
+ numberingSystem,
+ timeZone,
+ hourCycle,
+ dateStyle,
+ timeStyle,
+ weekday,
+ era,
+ year,
+ month,
+ day,
+ dayPeriod,
+ hour,
+ minute,
+ second,
+ fractionalSecondDigits,
+ timeZoneName,
+ dateTimeFormatInfo,
+ culture);
+ }
+
+ private string? GetStringOption(ObjectInstance options, string property, HashSet values, string? fallback)
+ {
+ var value = options.Get(property);
+ if (value.IsUndefined())
+ {
+ return fallback;
+ }
+
+ var stringValue = TypeConverter.ToString(value);
+
+ if (values != null && values.Count > 0)
+ {
+ if (!values.Contains(stringValue))
+ {
+ Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'");
+ }
+ }
+
+ return stringValue;
+ }
+
+ private string? GetUnicodeExtensionOption(ObjectInstance options, string property)
+ {
+ var value = options.Get(property);
+ if (value.IsUndefined())
+ {
+ return null;
+ }
+
+ var stringValue = TypeConverter.ToString(value);
+
+ // Validate against pattern: (3*8alphanum) *("-" (3*8alphanum))
+ if (!IntlUtilities.IsValidUnicodeExtensionValue(stringValue))
+ {
+ Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'");
+ }
+
+ return stringValue;
+ }
+
+ private string? GetNumberingSystemOption(ObjectInstance options, List requestedLocales)
+ {
+ var value = options.Get("numberingSystem");
+ string? requestedNS = null;
+
+ if (!value.IsUndefined())
+ {
+ requestedNS = TypeConverter.ToString(value);
+
+ // Validate against pattern
+ if (!IntlUtilities.IsValidUnicodeExtensionValue(requestedNS))
+ {
+ Throw.RangeError(_realm, $"Invalid value '{requestedNS}' for option 'numberingSystem'");
+ }
+ }
+ else if (requestedLocales.Count > 0)
+ {
+ // Check for unicode extension -u-nu-
+ requestedNS = ExtractUnicodeExtensionFromLocale(requestedLocales[0], "nu");
+ }
+
+ // Validate against supported numbering systems
+ // Only return numbering systems we actually support (have digit mappings for)
+ if (requestedNS != null)
+ {
+ var supported = _engine.Options.Intl.CldrProvider.GetSupportedNumberingSystems();
+ foreach (var ns in supported)
+ {
+ if (string.Equals(ns, requestedNS, StringComparison.OrdinalIgnoreCase))
+ {
+ return ns; // Return canonical form
+ }
+ }
+ // Unsupported numbering system - fall back to null (will use locale default)
+ return null;
+ }
+
+ return null;
+ }
+
+ private int? GetNumberOption(ObjectInstance options, string property, int minimum, int maximum, int? fallback)
+ {
+ var value = options.Get(property);
+ if (value.IsUndefined())
{
- newTarget = this;
+ return fallback;
}
- var dateTimeFormat = OrdinaryCreateFromConstructor(
- newTarget,
- static intrinsics => intrinsics.DateTimeFormat.PrototypeObject,
- static (engine, _, _) => new JsObject(engine));
+ var number = TypeConverter.ToNumber(value);
- InitializeDateTimeFormat(dateTimeFormat, locales, options);
- return dateTimeFormat;
+ // Per spec: check NaN and bounds BEFORE flooring
+ if (double.IsNaN(number) || number < minimum || number > maximum)
+ {
+ Throw.RangeError(_realm, $"Invalid value for option '{property}'");
+ }
+
+ // Return floor(value)
+ return (int) System.Math.Floor(number);
+ }
+
+ private string? GetTimeZoneOption(ObjectInstance options)
+ {
+ var value = options.Get("timeZone");
+ if (value.IsUndefined())
+ {
+ return null;
+ }
+
+ var timeZone = TypeConverter.ToString(value);
+
+ // Validate and canonicalize time zone using IANA database
+ // Per ECMA-402, only IANA timezone identifiers are allowed
+ if (string.Equals(timeZone, "UTC", StringComparison.OrdinalIgnoreCase))
+ {
+ return "UTC";
+ }
+
+ // Check for offset time zones like "+03", "-07:30", "+05:45"
+ var canonicalOffset = TryParseOffsetTimeZone(timeZone);
+ if (canonicalOffset != null)
+ {
+ return canonicalOffset;
+ }
+
+ // Look up in our IANA timezone database (case-insensitive)
+ // This is the only source of valid timezone names per ECMA-402
+ var canonicalId = Data.TimeZoneData.FindCanonical(timeZone);
+ if (canonicalId != null)
+ {
+ return canonicalId;
+ }
+
+ // Not a valid IANA timezone identifier
+ Throw.RangeError(_realm, $"Invalid time zone: {timeZone}");
+ return null;
+ }
+
+ ///
+ /// Parses and canonicalizes an offset time zone string.
+ /// Valid formats: "+HH", "-HH", "+HH:MM", "-HH:MM", "+HHMM", "-HHMM"
+ /// Returns canonical form like "+03:00" or "-07:30", or null if not a valid offset.
+ ///
+ private static string? TryParseOffsetTimeZone(string timeZone)
+ {
+ if (string.IsNullOrEmpty(timeZone) || timeZone.Length < 3)
+ {
+ return null;
+ }
+
+ var sign = timeZone[0];
+ if (sign != '+' && sign != '-')
+ {
+ return null;
+ }
+
+ var len = timeZone.Length;
+ int hours;
+ int minutes = 0;
+
+ // Parse based on format with strict position validation
+ if (len == 3)
+ {
+ // "+HH" or "-HH" format - positions 1,2 must be digits
+ if (!char.IsDigit(timeZone[1]) || !char.IsDigit(timeZone[2]))
+ {
+ return null;
+ }
+ hours = (timeZone[1] - '0') * 10 + (timeZone[2] - '0');
+ }
+ else if (len == 5)
+ {
+ // "+HHMM" format - positions 1,2,3,4 must all be digits
+ if (!char.IsDigit(timeZone[1]) || !char.IsDigit(timeZone[2]) ||
+ !char.IsDigit(timeZone[3]) || !char.IsDigit(timeZone[4]))
+ {
+ return null;
+ }
+ hours = (timeZone[1] - '0') * 10 + (timeZone[2] - '0');
+ minutes = (timeZone[3] - '0') * 10 + (timeZone[4] - '0');
+ }
+ else if (len == 6 && timeZone[3] == ':')
+ {
+ // "+HH:MM" format - positions 1,2 and 4,5 must be digits, position 3 is ':'
+ if (!char.IsDigit(timeZone[1]) || !char.IsDigit(timeZone[2]) ||
+ !char.IsDigit(timeZone[4]) || !char.IsDigit(timeZone[5]))
+ {
+ return null;
+ }
+ hours = (timeZone[1] - '0') * 10 + (timeZone[2] - '0');
+ minutes = (timeZone[4] - '0') * 10 + (timeZone[5] - '0');
+ }
+ else
+ {
+ return null;
+ }
+
+ // Validate ranges: hours 0-23, minutes 0-59
+ // Note: ECMA-402 allows offsets up to ±23:59
+ if (hours > 23 || minutes > 59)
+ {
+ return null;
+ }
+
+ // Return canonical form: ±HH:MM
+ // Per ECMA-402, -00:00 is normalized to +00:00
+ if (hours == 0 && minutes == 0)
+ {
+ return "+00:00";
+ }
+
+ return $"{sign}{hours:D2}:{minutes:D2}";
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.datetimeformat.supportedlocalesof
+ ///
+ private JsArray SupportedLocalesOf(JsValue thisObject, JsCallArguments arguments)
+ {
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales);
+ var availableLocales = IntlUtilities.GetAvailableLocales();
+
+ // Validate localeMatcher option
+ var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options);
+ GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, fallback: null);
+
+ List supported = [];
+ foreach (var locale in requestedLocales)
+ {
+ var bestAvailable = IntlUtilities.BestAvailableLocale(availableLocales, locale);
+ if (bestAvailable != null)
+ {
+ supported.Add(locale);
+ }
+ }
+
+ return new JsArray(_engine, supported.ToArray());
}
///
- /// https://tc39.es/ecma402/#sec-initializedatetimeformat
+ /// Checks if the given calendar is supported.
///
- private static void InitializeDateTimeFormat(JsObject dateTimeFormat, JsValue locales, JsValue options)
+ private static bool IsValidCalendar(string? calendar)
{
- // TODO
+ return calendar != null && SupportedCalendars.Contains(calendar);
+ }
+
+ ///
+ /// Builds the resolved locale string, including only the unicode extensions that should be preserved.
+ ///
+ private static string BuildResolvedLocale(string baseLocale, string requestedLocale,
+ string? calendar, string? hourCycle, string? numberingSystem)
+ {
+ // If no extensions to preserve, return base locale (without any extensions)
+ if (calendar == null && hourCycle == null && numberingSystem == null)
+ {
+ return baseLocale;
+ }
+
+ // Build the unicode extension string
+ var extensions = new List();
+
+ // Extensions are added in alphabetical order by key
+ if (calendar != null)
+ {
+ extensions.Add($"ca-{calendar}");
+ }
+ if (hourCycle != null)
+ {
+ extensions.Add($"hc-{hourCycle}");
+ }
+ if (numberingSystem != null)
+ {
+ extensions.Add($"nu-{numberingSystem}");
+ }
+
+ if (extensions.Count == 0)
+ {
+ return baseLocale;
+ }
+
+ return $"{baseLocale}-u-{string.Join("-", extensions)}";
+ }
+
+ ///
+ /// Canonicalizes calendar names per ECMA-402.
+ /// Converts deprecated/alias names to their canonical forms.
+ /// Per ECMA-402, "islamic" and "islamic-rgsa" should fallback to a valid calendar
+ /// from AvailableCalendars (like "islamic-civil").
+ ///
+ private static string? CanonicalizeCalendar(string? calendar)
+ {
+ if (calendar == null)
+ {
+ return null;
+ }
+
+ // Calendar alias and fallback mappings per Unicode CLDR and ECMA-402
+ return calendar.ToLowerInvariant() switch
+ {
+ "ethiopic-amete-alem" => "ethioaa",
+ "islamicc" => "islamic-civil",
+ // Per ECMA-402 §11.1.2, "islamic" and "islamic-rgsa" are deprecated
+ // and should fallback to a valid calendar from AvailableCalendars
+ "islamic" => "islamic-civil",
+ "islamic-rgsa" => "islamic-civil",
+ _ => calendar.ToLowerInvariant()
+ };
+ }
+
+ ///
+ /// Extracts a unicode extension value from the locale list.
+ /// For example, extracts "h11" from "de-u-hc-h11" when key is "hc".
+ ///
+ private static string? ExtractUnicodeExtensionValue(List locales, string key, HashSet? validValues)
+ {
+ foreach (var locale in locales)
+ {
+ var value = ExtractUnicodeExtensionFromLocale(locale, key);
+ if (value != null)
+ {
+ // Validate against allowed values if provided
+ if (validValues == null || validValues.Contains(value))
+ {
+ return value;
+ }
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Extracts a specific unicode extension value from a single locale string.
+ ///
+ private static string? ExtractUnicodeExtensionFromLocale(string locale, string key)
+ {
+ // Look for -u- marker
+ var uIndex = locale.IndexOf("-u-", StringComparison.OrdinalIgnoreCase);
+ if (uIndex == -1)
+ {
+ return null;
+ }
+
+ // Parse the unicode extension looking for the key
+ var extensionStart = uIndex + 3;
+ var i = extensionStart;
+
+ while (i < locale.Length)
+ {
+ // Find the key (2 characters)
+ var keyStart = i;
+ while (i < locale.Length && locale[i] != '-')
+ {
+ i++;
+ }
+
+ var currentKey = locale.Substring(keyStart, i - keyStart);
+
+ // Move past the '-' if present
+ if (i < locale.Length && locale[i] == '-')
+ {
+ i++;
+ }
+
+ // Check if this is a 2-character key (not a singleton for next extension)
+ if (currentKey.Length == 2)
+ {
+ // Find the value(s) - collect until next key or end
+ var valueStart = i;
+ while (i < locale.Length)
+ {
+ var partStart = i;
+ while (i < locale.Length && locale[i] != '-')
+ {
+ i++;
+ }
+
+ var part = locale.Substring(partStart, i - partStart);
+
+ // If this part is a 2-char key or 1-char singleton, stop
+ if (part.Length == 2 || part.Length == 1)
+ {
+ break;
+ }
+
+ // Move past '-' if present
+ if (i < locale.Length && locale[i] == '-')
+ {
+ i++;
+ }
+ }
+
+ var value = locale.Substring(valueStart, i - valueStart).TrimEnd('-');
+
+ if (string.Equals(currentKey, key, StringComparison.OrdinalIgnoreCase) && value.Length > 0)
+ {
+ return value.ToLowerInvariant();
+ }
+ }
+ else if (currentKey.Length == 1)
+ {
+ // This is a singleton starting a new extension type, stop parsing unicode extension
+ break;
+ }
+ }
+
+ return null;
}
}
diff --git a/Jint/Native/Intl/DateTimeFormatPrototype.cs b/Jint/Native/Intl/DateTimeFormatPrototype.cs
index db5a75789e..50e4c01a58 100644
--- a/Jint/Native/Intl/DateTimeFormatPrototype.cs
+++ b/Jint/Native/Intl/DateTimeFormatPrototype.cs
@@ -1,7 +1,9 @@
+using Jint.Native.Date;
using Jint.Native.Object;
using Jint.Native.Symbol;
using Jint.Runtime;
using Jint.Runtime.Descriptors;
+using Jint.Runtime.Interop;
namespace Jint.Native.Intl;
@@ -23,16 +25,789 @@ public DateTimeFormatPrototype(Engine engine,
protected override void Initialize()
{
- var properties = new PropertyDictionary(2, checkExistingKeys: false)
+ const PropertyFlag LengthFlags = PropertyFlag.Configurable;
+ const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable;
+
+ var properties = new PropertyDictionary(5, checkExistingKeys: false)
{
- ["constructor"] = new PropertyDescriptor(_constructor, true, false, true),
+ ["constructor"] = new PropertyDescriptor(_constructor, PropertyFlag.NonEnumerable),
+ ["resolvedOptions"] = new PropertyDescriptor(new ClrFunction(Engine, "resolvedOptions", ResolvedOptions, 0, LengthFlags), PropertyFlags),
+ ["formatToParts"] = new PropertyDescriptor(new ClrFunction(Engine, "formatToParts", FormatToParts, 1, LengthFlags), PropertyFlags),
+ ["formatRange"] = new PropertyDescriptor(new ClrFunction(Engine, "formatRange", FormatRange, 2, LengthFlags), PropertyFlags),
+ ["formatRangeToParts"] = new PropertyDescriptor(new ClrFunction(Engine, "formatRangeToParts", FormatRangeToParts, 2, LengthFlags), PropertyFlags),
};
SetProperties(properties);
+ // format is an accessor property - accessor properties don't have writable attribute
+ SetAccessor("format", new GetSetPropertyDescriptor(
+ new ClrFunction(Engine, "get format", GetFormat, 0, LengthFlags),
+ Undefined,
+ PropertyFlag.Configurable));
+
var symbols = new SymbolDictionary(1)
{
[GlobalSymbolRegistry.ToStringTag] = new("Intl.DateTimeFormat", PropertyFlag.Configurable)
};
SetSymbols(symbols);
}
+
+ private void SetAccessor(string name, GetSetPropertyDescriptor descriptor)
+ {
+ SetProperty(name, descriptor);
+ }
+
+ private JsDateTimeFormat ValidateDateTimeFormat(JsValue thisObject)
+ {
+ if (thisObject is JsDateTimeFormat dateTimeFormat)
+ {
+ return dateTimeFormat;
+ }
+
+ Throw.TypeError(_realm, "Value is not an Intl.DateTimeFormat");
+ return null!; // Never reached
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.format
+ ///
+ private ClrFunction GetFormat(JsValue thisObject, JsCallArguments arguments)
+ {
+ var dateTimeFormat = ValidateDateTimeFormat(thisObject);
+
+ // Return a bound format function
+ return new ClrFunction(Engine, "", (_, args) =>
+ {
+ var dateValue = args.At(0);
+ var dateTime = ToDateTimeWithOriginalYear(dateValue, out var originalYear);
+ return dateTimeFormat.Format(dateTime, originalYear);
+ }, 1, PropertyFlag.Configurable);
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.formattoparts
+ ///
+ private JsArray FormatToParts(JsValue thisObject, JsCallArguments arguments)
+ {
+ var dateTimeFormat = ValidateDateTimeFormat(thisObject);
+ var dateValue = arguments.At(0);
+ var dateTime = ToDateTimeWithOriginalYear(dateValue, out var originalYear);
+
+ var parts = dateTimeFormat.FormatToParts(dateTime, originalYear);
+ var result = new JsArray(Engine, (uint) parts.Count);
+
+ for (var i = 0; i < parts.Count; i++)
+ {
+ var partObj = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
+ partObj.Set("type", parts[i].Type);
+ partObj.Set("value", parts[i].Value);
+ result.SetIndexValue((uint) i, partObj, updateLength: true);
+ }
+
+ return result;
+ }
+
+ private DateTime ToDateTime(JsValue value)
+ {
+ if (value.IsUndefined())
+ {
+ return DateTime.Now;
+ }
+
+ if (value is JsDate jsDate)
+ {
+ // Check if date is within .NET DateTime range
+ if (!jsDate.DateTimeRangeValid)
+ {
+ // Date is outside .NET range - return min/max based on sign
+ return jsDate.DateValue < 0 ? DateTime.MinValue : DateTime.MaxValue;
+ }
+
+ // ECMA-402 requires formatting in local time unless a specific timezone is provided
+ var dt = jsDate.ToDateTime();
+ if (dt.Kind == DateTimeKind.Utc || dt.Kind == DateTimeKind.Unspecified)
+ {
+ dt = dt.ToLocalTime();
+ }
+ return dt;
+ }
+
+ var timeValue = TypeConverter.ToNumber(value);
+ DatePresentation presentation = timeValue;
+ presentation = presentation.TimeClip();
+
+ if (presentation.IsNaN)
+ {
+ Throw.RangeError(_realm, "Invalid time value");
+ }
+
+ // Clamp to .NET DateTime range if necessary
+ if (presentation.Value < JsDate.Min)
+ {
+ return DateTime.MinValue;
+ }
+ if (presentation.Value > JsDate.Max)
+ {
+ return DateTime.MaxValue;
+ }
+
+ return presentation.ToDateTime().ToLocalTime();
+ }
+
+ ///
+ /// Converts a JavaScript value to DateTime, returning the original JavaScript year
+ /// when the date is outside .NET DateTime range (for proper era formatting).
+ ///
+ private DateTime ToDateTimeWithOriginalYear(JsValue value, out int? originalYear)
+ {
+ if (value.IsUndefined())
+ {
+ originalYear = null;
+ return DateTime.Now;
+ }
+
+ if (value is JsDate jsDate)
+ {
+ // Check if date is within .NET DateTime range
+ if (!jsDate.DateTimeRangeValid)
+ {
+ // Extract the original JavaScript year from the date value
+ originalYear = GetJavaScriptYear(jsDate.DateValue);
+ // Date is outside .NET range - return min/max based on sign
+ return jsDate.DateValue < 0 ? DateTime.MinValue : DateTime.MaxValue;
+ }
+
+ // ECMA-402 requires formatting in local time unless a specific timezone is provided
+ var dateTime = jsDate.ToDateTime();
+ if (dateTime.Kind == DateTimeKind.Utc || dateTime.Kind == DateTimeKind.Unspecified)
+ {
+ dateTime = dateTime.ToLocalTime();
+ }
+ originalYear = null;
+ return dateTime;
+ }
+
+ var timeValue = TypeConverter.ToNumber(value);
+ DatePresentation presentation = timeValue;
+ presentation = presentation.TimeClip();
+
+ if (presentation.IsNaN)
+ {
+ Throw.RangeError(_realm, "Invalid time value");
+ }
+
+ // Clamp to .NET DateTime range if necessary
+ if (presentation.Value < JsDate.Min)
+ {
+ originalYear = GetJavaScriptYear(presentation.Value);
+ return DateTime.MinValue;
+ }
+
+ if (presentation.Value > JsDate.Max)
+ {
+ originalYear = GetJavaScriptYear(presentation.Value);
+ return DateTime.MaxValue;
+ }
+
+ originalYear = null;
+ return presentation.ToDateTime().ToLocalTime();
+ }
+
+ ///
+ /// Extracts the JavaScript year from a timestamp value using the ECMAScript algorithm.
+ /// JavaScript Date uses milliseconds since Unix epoch (1970-01-01).
+ ///
+ private static int GetJavaScriptYear(double timeValue)
+ {
+ // ECMAScript YearFromTime algorithm
+ // https://tc39.es/ecma262/#sec-yearfromtime
+ const double msPerDay = 86400000;
+
+ // Day number from epoch
+ var day = System.Math.Floor(timeValue / msPerDay);
+
+ // Estimate year (rough approximation to start binary search)
+ var year = (int) (1970 + day / 365.2425);
+
+ // Binary search refinement for exact year
+ while (DayFromYear(year + 1) <= day)
+ {
+ year++;
+ }
+ while (DayFromYear(year) > day)
+ {
+ year--;
+ }
+
+ return year;
+ }
+
+ ///
+ /// Returns the day number of the first day of a given year (ECMAScript algorithm).
+ ///
+ private static double DayFromYear(int year)
+ {
+ // https://tc39.es/ecma262/#sec-dayfromyear
+ return 365.0 * (year - 1970)
+ + System.Math.Floor((year - 1969) / 4.0)
+ - System.Math.Floor((year - 1901) / 100.0)
+ + System.Math.Floor((year - 1601) / 400.0);
+ }
+
+ ///
+ /// Gets the default hour cycle for a locale.
+ /// Most locales use h12, but some use h23 (24-hour format without leading zero for midnight).
+ ///
+ private static string GetDefaultHourCycle(string locale)
+ {
+ // Most English-speaking locales use 12-hour format
+ var lang = locale.Split('-')[0].ToLowerInvariant();
+
+ // 24-hour format locales
+ if (lang is "de" or "fr" or "it" or "es" or "pt" or "nl" or "ru" or "pl" or "sv" or "da" or "nb" or "fi")
+ {
+ return "h23";
+ }
+
+ // Japanese uses h11 for 12-hour format (0-11 instead of 1-12)
+ if (string.Equals(lang, "ja", StringComparison.Ordinal))
+ {
+ return "h11";
+ }
+
+ // Default to 12-hour format h12 (1-12)
+ return "h12";
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.resolvedoptions
+ ///
+ private JsObject ResolvedOptions(JsValue thisObject, JsCallArguments arguments)
+ {
+ var dateTimeFormat = ValidateDateTimeFormat(thisObject);
+
+ var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
+
+ // Use CreateDataPropertyOrThrow to avoid prototype chain setters
+ result.CreateDataPropertyOrThrow("locale", dateTimeFormat.Locale);
+
+ if (dateTimeFormat.Calendar != null)
+ {
+ result.CreateDataPropertyOrThrow("calendar", dateTimeFormat.Calendar);
+ }
+ else
+ {
+ result.CreateDataPropertyOrThrow("calendar", "gregory");
+ }
+
+ result.CreateDataPropertyOrThrow("numberingSystem", dateTimeFormat.NumberingSystem ?? "latn");
+
+ // timeZone is always present - use local timezone if not specified
+ // Convert Windows timezone IDs to IANA format per ECMA-402
+ var timeZoneId = dateTimeFormat.TimeZone ?? TimeZoneInfo.Local.Id;
+ result.CreateDataPropertyOrThrow("timeZone", ToIanaTimeZoneId(timeZoneId));
+
+ // hourCycle and hour12 should be returned if hour is present OR if timeStyle is set
+ // Per ECMA-402, timeStyle implies hour formatting
+ if (dateTimeFormat.Hour != null || dateTimeFormat.TimeStyle != null)
+ {
+ // Use provided hourCycle or derive default from locale
+ var hourCycle = dateTimeFormat.HourCycle ?? GetDefaultHourCycle(dateTimeFormat.Locale);
+ result.CreateDataPropertyOrThrow("hourCycle", hourCycle);
+ result.CreateDataPropertyOrThrow("hour12", string.Equals(hourCycle, "h11", StringComparison.Ordinal) ||
+ string.Equals(hourCycle, "h12", StringComparison.Ordinal));
+ }
+
+ if (dateTimeFormat.DateStyle != null)
+ {
+ result.CreateDataPropertyOrThrow("dateStyle", dateTimeFormat.DateStyle);
+ }
+
+ if (dateTimeFormat.TimeStyle != null)
+ {
+ result.CreateDataPropertyOrThrow("timeStyle", dateTimeFormat.TimeStyle);
+ }
+
+ // Component options
+ if (dateTimeFormat.Weekday != null)
+ {
+ result.CreateDataPropertyOrThrow("weekday", dateTimeFormat.Weekday);
+ }
+
+ if (dateTimeFormat.Era != null)
+ {
+ result.CreateDataPropertyOrThrow("era", dateTimeFormat.Era);
+ }
+
+ if (dateTimeFormat.Year != null)
+ {
+ result.CreateDataPropertyOrThrow("year", dateTimeFormat.Year);
+ }
+
+ if (dateTimeFormat.Month != null)
+ {
+ result.CreateDataPropertyOrThrow("month", dateTimeFormat.Month);
+ }
+
+ if (dateTimeFormat.Day != null)
+ {
+ result.CreateDataPropertyOrThrow("day", dateTimeFormat.Day);
+ }
+
+ // dayPeriod comes after day and before hour per ECMA-402 spec order
+ if (dateTimeFormat.DayPeriod != null)
+ {
+ result.CreateDataPropertyOrThrow("dayPeriod", dateTimeFormat.DayPeriod);
+ }
+
+ if (dateTimeFormat.Hour != null)
+ {
+ result.CreateDataPropertyOrThrow("hour", dateTimeFormat.Hour);
+ }
+
+ if (dateTimeFormat.Minute != null)
+ {
+ result.CreateDataPropertyOrThrow("minute", dateTimeFormat.Minute);
+ }
+
+ if (dateTimeFormat.Second != null)
+ {
+ result.CreateDataPropertyOrThrow("second", dateTimeFormat.Second);
+ }
+
+ if (dateTimeFormat.FractionalSecondDigits.HasValue)
+ {
+ result.CreateDataPropertyOrThrow("fractionalSecondDigits", dateTimeFormat.FractionalSecondDigits.Value);
+ }
+
+ if (dateTimeFormat.TimeZoneName != null)
+ {
+ result.CreateDataPropertyOrThrow("timeZoneName", dateTimeFormat.TimeZoneName);
+ }
+
+ return result;
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.formatrange
+ ///
+ private JsValue FormatRange(JsValue thisObject, JsCallArguments arguments)
+ {
+ var dateTimeFormat = ValidateDateTimeFormat(thisObject);
+
+ var startDate = arguments.At(0);
+ var endDate = arguments.At(1);
+
+ // Validate arguments
+ if (startDate.IsUndefined() || endDate.IsUndefined())
+ {
+ Throw.TypeError(_realm, "startDate and endDate are required");
+ }
+
+ var start = ToDateTimeForRange(startDate);
+ var end = ToDateTimeForRange(endDate);
+
+ // Format both dates
+ var startFormatted = dateTimeFormat.Format(start);
+ var endFormatted = dateTimeFormat.Format(end);
+
+ // If the dates are the same when formatted, return just one
+ if (string.Equals(startFormatted, endFormatted, StringComparison.Ordinal))
+ {
+ return startFormatted;
+ }
+
+ // Get parts for both dates to apply collapsing logic
+ var startParts = dateTimeFormat.FormatToParts(start);
+ var endParts = dateTimeFormat.FormatToParts(end);
+
+ // Determine if we should use interval collapsing
+ var useCollapsing = ShouldUseIntervalCollapsing(dateTimeFormat, startParts, endParts);
+
+ if (useCollapsing)
+ {
+ // Build collapsed range string
+ var sharedPrefixEnd = FindSharedPrefixEnd(startParts, endParts);
+ var sharedSuffixStart = FindSharedSuffixStart(startParts, endParts, sharedPrefixEnd);
+
+ // Only collapse if there's a shared suffix
+ // If the year (last component) differs, we should output full dates
+ var hasSuffix = sharedSuffixStart < startParts.Count;
+
+ if (hasSuffix)
+ {
+ var result = new System.Text.StringBuilder();
+
+ // Add shared prefix
+ for (var i = 0; i < sharedPrefixEnd; i++)
+ {
+ result.Append(startParts[i].Value);
+ }
+
+ // Add start range differing parts
+ for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++)
+ {
+ result.Append(startParts[i].Value);
+ }
+
+ // Add separator
+ result.Append(" – ");
+
+ // Add end range differing parts
+ for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++)
+ {
+ result.Append(endParts[i].Value);
+ }
+
+ // Add shared suffix
+ for (var i = sharedSuffixStart; i < startParts.Count; i++)
+ {
+ result.Append(startParts[i].Value);
+ }
+
+ return result.ToString();
+ }
+ }
+
+ // Return a range string without collapsing
+ return $"{startFormatted} – {endFormatted}";
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.formatrangetoparts
+ ///
+ private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments)
+ {
+ var dateTimeFormat = ValidateDateTimeFormat(thisObject);
+
+ var startDate = arguments.At(0);
+ var endDate = arguments.At(1);
+
+ // Validate arguments
+ if (startDate.IsUndefined() || endDate.IsUndefined())
+ {
+ Throw.TypeError(_realm, "startDate and endDate are required");
+ }
+
+ var start = ToDateTimeForRange(startDate);
+ var end = ToDateTimeForRange(endDate);
+
+ // Get parts for both dates
+ var startParts = dateTimeFormat.FormatToParts(start);
+ var endParts = dateTimeFormat.FormatToParts(end);
+
+ // Check if dates are practically equal (same formatted output)
+ var startFormatted = dateTimeFormat.Format(start);
+ var endFormatted = dateTimeFormat.Format(end);
+
+ var result = new JsArray(Engine);
+ uint index = 0;
+
+ if (string.Equals(startFormatted, endFormatted, StringComparison.Ordinal))
+ {
+ // Dates are practically equal - return parts with source "shared"
+ foreach (var part in startParts)
+ {
+ var partObj = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
+ partObj.Set("type", part.Type);
+ partObj.Set("value", part.Value);
+ partObj.Set("source", "shared");
+ result.SetIndexValue(index++, partObj, updateLength: true);
+ }
+ }
+ else
+ {
+ // Determine if we should use interval collapsing
+ // Collapsing is used when explicit component options are set (not dateStyle/timeStyle)
+ // and the month is textual (not numeric)
+ var useCollapsing = ShouldUseIntervalCollapsing(dateTimeFormat, startParts, endParts);
+
+ if (useCollapsing)
+ {
+ // Find shared prefix and suffix
+ var sharedPrefixEnd = FindSharedPrefixEnd(startParts, endParts);
+ var sharedSuffixStart = FindSharedSuffixStart(startParts, endParts, sharedPrefixEnd);
+
+ // Only collapse if there's a shared suffix
+ // If the year (last component) differs, we should output full dates
+ var hasSuffix = sharedSuffixStart < startParts.Count;
+
+ if (hasSuffix)
+ {
+ // Add shared prefix
+ for (var i = 0; i < sharedPrefixEnd; i++)
+ {
+ AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared");
+ }
+
+ // Add start range differing parts
+ for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++)
+ {
+ AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "startRange");
+ }
+
+ // Add separator
+ AddPartToResult(result, ref index, "literal", " – ", "shared");
+
+ // Add end range differing parts
+ for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++)
+ {
+ AddPartToResult(result, ref index, endParts[i].Type, endParts[i].Value, "endRange");
+ }
+
+ // Add shared suffix
+ for (var i = sharedSuffixStart; i < startParts.Count; i++)
+ {
+ AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared");
+ }
+ }
+ else
+ {
+ // No suffix to share - output full dates
+ foreach (var part in startParts)
+ {
+ AddPartToResult(result, ref index, part.Type, part.Value, "startRange");
+ }
+
+ AddPartToResult(result, ref index, "literal", " – ", "shared");
+
+ foreach (var part in endParts)
+ {
+ AddPartToResult(result, ref index, part.Type, part.Value, "endRange");
+ }
+ }
+ }
+ else
+ {
+ // No collapsing - output full dates with separator
+ foreach (var part in startParts)
+ {
+ AddPartToResult(result, ref index, part.Type, part.Value, "startRange");
+ }
+
+ AddPartToResult(result, ref index, "literal", " – ", "shared");
+
+ foreach (var part in endParts)
+ {
+ AddPartToResult(result, ref index, part.Type, part.Value, "endRange");
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private void AddPartToResult(JsArray result, ref uint index, string type, string value, string source)
+ {
+ var partObj = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
+ partObj.Set("type", type);
+ partObj.Set("value", value);
+ partObj.Set("source", source);
+ result.SetIndexValue(index++, partObj, updateLength: true);
+ }
+
+ private static bool ShouldUseIntervalCollapsing(JsDateTimeFormat format, List startParts, List endParts)
+ {
+ // Don't collapse if using dateStyle/timeStyle
+ if (format.DateStyle != null || format.TimeStyle != null)
+ {
+ return false;
+ }
+
+ // Don't collapse if parts have different lengths
+ if (startParts.Count != endParts.Count)
+ {
+ return false;
+ }
+
+ // Use collapsing when month is textual (short, long, narrow)
+ // and we have explicit component options
+ var hasTextualMonth = format.Month is "short" or "long" or "narrow";
+ var hasExplicitComponents = format.Year != null || format.Month != null || format.Day != null;
+
+ return hasTextualMonth && hasExplicitComponents;
+ }
+
+ private static int FindSharedPrefixEnd(List startParts, List endParts)
+ {
+ var minLen = System.Math.Min(startParts.Count, endParts.Count);
+ var i = 0;
+
+ while (i < minLen)
+ {
+ var startPart = startParts[i];
+ var endPart = endParts[i];
+
+ // Parts match if type and value are the same
+ if (!string.Equals(startPart.Type, endPart.Type, StringComparison.Ordinal) ||
+ !string.Equals(startPart.Value, endPart.Value, StringComparison.Ordinal))
+ {
+ break;
+ }
+
+ i++;
+ }
+
+ // Include trailing literal in prefix if the next non-literal parts also match
+ // This handles cases like "Jan " where the space should be shared
+ while (i > 0 && i < minLen &&
+ string.Equals(startParts[i - 1].Type, "literal", StringComparison.Ordinal))
+ {
+ // Check if the previous non-literal parts match
+ var prevNonLiteral = i - 2;
+ while (prevNonLiteral >= 0 && string.Equals(startParts[prevNonLiteral].Type, "literal", StringComparison.Ordinal))
+ {
+ prevNonLiteral--;
+ }
+
+ if (prevNonLiteral >= 0 &&
+ string.Equals(startParts[prevNonLiteral].Value, endParts[prevNonLiteral].Value, StringComparison.Ordinal))
+ {
+ // Include the literal in the prefix
+ break;
+ }
+ else
+ {
+ // Don't include trailing literal that precedes different values
+ i--;
+ }
+ }
+
+ return i;
+ }
+
+ private static int FindSharedSuffixStart(List startParts, List endParts, int prefixEnd)
+ {
+ var startLen = startParts.Count;
+ var endLen = endParts.Count;
+
+ if (startLen != endLen)
+ {
+ return startLen;
+ }
+
+ var i = startLen - 1;
+
+ while (i >= prefixEnd)
+ {
+ var startPart = startParts[i];
+ var endPart = endParts[i];
+
+ // Parts match if type and value are the same
+ if (!string.Equals(startPart.Type, endPart.Type, StringComparison.Ordinal) ||
+ !string.Equals(startPart.Value, endPart.Value, StringComparison.Ordinal))
+ {
+ break;
+ }
+
+ i--;
+ }
+
+ // Move past the last differing part to get suffix start
+ return i + 1;
+ }
+
+ private DateTime ToDateTimeForRange(JsValue value)
+ {
+ if (value is JsDate jsDate)
+ {
+ // Check if date is within .NET DateTime range
+ if (!jsDate.DateTimeRangeValid)
+ {
+ // Date is outside .NET range - return min/max based on sign
+ return jsDate.DateValue < 0 ? DateTime.MinValue : DateTime.MaxValue;
+ }
+
+ var dt = jsDate.ToDateTime();
+ if (dt == DateTime.MinValue)
+ {
+ // Invalid date
+ Throw.RangeError(_realm, "Invalid time value");
+ }
+ // ECMA-402 requires formatting in local time unless a specific timezone is provided
+ if (dt.Kind == DateTimeKind.Utc || dt.Kind == DateTimeKind.Unspecified)
+ {
+ dt = dt.ToLocalTime();
+ }
+ return dt;
+ }
+
+ var timeValue = TypeConverter.ToNumber(value);
+ DatePresentation presentation = timeValue;
+ presentation = presentation.TimeClip();
+
+ if (presentation.IsNaN)
+ {
+ Throw.RangeError(_realm, "Invalid time value");
+ }
+
+ // Clamp to .NET DateTime range if necessary
+ if (presentation.Value < JsDate.Min)
+ {
+ return DateTime.MinValue;
+ }
+ if (presentation.Value > JsDate.Max)
+ {
+ return DateTime.MaxValue;
+ }
+
+ return presentation.ToDateTime().ToLocalTime();
+ }
+
+ ///
+ /// Converts a timezone ID to IANA format.
+ /// Windows uses names like "FLE Standard Time" but ECMA-402 requires IANA names like "Europe/Helsinki".
+ ///
+ private static string ToIanaTimeZoneId(string timeZoneId)
+ {
+ // UTC is already valid
+ if (string.Equals(timeZoneId, "UTC", StringComparison.OrdinalIgnoreCase))
+ {
+ return "UTC";
+ }
+
+ // Offset timezones are already valid (e.g., "+03:00")
+ if (timeZoneId.Length > 0 && (timeZoneId[0] == '+' || timeZoneId[0] == '-'))
+ {
+ return timeZoneId;
+ }
+
+#if NET6_0_OR_GREATER
+ // Try to convert Windows ID to IANA
+ if (TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZoneId, out var ianaId))
+ {
+ return ianaId;
+ }
+#endif
+
+ // Check if it's already an IANA ID (contains '/')
+ if (timeZoneId.Contains('/'))
+ {
+ return timeZoneId;
+ }
+
+ // Fallback: try to get the IANA ID from the TimeZoneInfo
+ try
+ {
+ var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
+#if NET6_0_OR_GREATER
+ // On .NET 6+, check if HasIanaId is available
+ if (tz.HasIanaId)
+ {
+ return tz.Id;
+ }
+ // Try conversion again with the found timezone
+ if (TimeZoneInfo.TryConvertWindowsIdToIanaId(tz.Id, out ianaId))
+ {
+ return ianaId;
+ }
+#endif
+ }
+ catch
+ {
+ // Ignore errors
+ }
+
+ // Last resort: return as-is (won't pass validation but better than crashing)
+ return timeZoneId;
+ }
}
diff --git a/Jint/Native/Intl/IntlInstance.cs b/Jint/Native/Intl/IntlInstance.cs
index 668b25ea73..e3acade5e4 100644
--- a/Jint/Native/Intl/IntlInstance.cs
+++ b/Jint/Native/Intl/IntlInstance.cs
@@ -35,6 +35,7 @@ protected override void Initialize()
var properties = new PropertyDictionary(4, checkExistingKeys: false)
{
["Collator"] = new(_realm.Intrinsics.Collator, PropertyFlags),
+ ["DateTimeFormat"] = new(_realm.Intrinsics.DateTimeFormat, PropertyFlags),
["Locale"] = new(_realm.Intrinsics.Locale, PropertyFlags),
["NumberFormat"] = new(_realm.Intrinsics.NumberFormat, PropertyFlags),
["PluralRules"] = new(_realm.Intrinsics.PluralRules, PropertyFlags),
diff --git a/Jint/Native/Intl/JsDateTimeFormat.cs b/Jint/Native/Intl/JsDateTimeFormat.cs
new file mode 100644
index 0000000000..6244697a99
--- /dev/null
+++ b/Jint/Native/Intl/JsDateTimeFormat.cs
@@ -0,0 +1,1593 @@
+using System.Globalization;
+using System.Text;
+using Jint.Native.Object;
+
+namespace Jint.Native.Intl;
+
+///
+/// https://tc39.es/ecma402/#sec-datetimeformat-objects
+/// Represents an Intl.DateTimeFormat instance with locale-aware date/time formatting.
+///
+internal sealed class JsDateTimeFormat : ObjectInstance
+{
+ internal JsDateTimeFormat(
+ Engine engine,
+ ObjectInstance prototype,
+ string locale,
+ string? calendar,
+ string? numberingSystem,
+ string? timeZone,
+ string? hourCycle,
+ string? dateStyle,
+ string? timeStyle,
+ string? weekday,
+ string? era,
+ string? year,
+ string? month,
+ string? day,
+ string? dayPeriod,
+ string? hour,
+ string? minute,
+ string? second,
+ int? fractionalSecondDigits,
+ string? timeZoneName,
+ DateTimeFormatInfo dateTimeFormatInfo,
+ CultureInfo cultureInfo) : base(engine)
+ {
+ _prototype = prototype;
+ Locale = locale;
+ Calendar = calendar;
+ NumberingSystem = numberingSystem;
+ TimeZone = timeZone;
+ HourCycle = hourCycle;
+ DateStyle = dateStyle;
+ TimeStyle = timeStyle;
+ Weekday = weekday;
+ Era = era;
+ Year = year;
+ Month = month;
+ Day = day;
+ DayPeriod = dayPeriod;
+ Hour = hour;
+ Minute = minute;
+ Second = second;
+ FractionalSecondDigits = fractionalSecondDigits;
+ TimeZoneName = timeZoneName;
+ DateTimeFormatInfo = dateTimeFormatInfo;
+ CultureInfo = cultureInfo;
+ }
+
+ internal string Locale { get; }
+ internal string? Calendar { get; }
+ internal string? NumberingSystem { get; }
+ internal string? TimeZone { get; }
+ internal string? HourCycle { get; }
+ internal string? DateStyle { get; }
+ internal string? TimeStyle { get; }
+ internal string? Weekday { get; }
+ internal string? Era { get; }
+ internal string? Year { get; }
+ internal string? Month { get; }
+ internal string? Day { get; }
+ internal string? DayPeriod { get; }
+ internal string? Hour { get; }
+ internal string? Minute { get; }
+ internal string? Second { get; }
+ internal int? FractionalSecondDigits { get; }
+ internal string? TimeZoneName { get; }
+ internal DateTimeFormatInfo DateTimeFormatInfo { get; }
+ internal CultureInfo CultureInfo { get; }
+
+ ///
+ /// Gets the CLDR provider from engine options.
+ ///
+ private ICldrProvider CldrProvider => _engine.Options.Intl.CldrProvider;
+
+ ///
+ /// Formats a date according to the formatter's locale and options.
+ ///
+ /// The .NET DateTime to format
+ /// Optional original JavaScript year (for dates outside .NET DateTime range)
+ internal string Format(DateTime dateTime, int? originalYear = null)
+ {
+ // For Chinese and Dangi calendars, use FormatToParts to get consistent output
+ // This ensures the special part types (relatedYear, yearName) are properly handled
+ var isLunisolarCalendar = string.Equals(Calendar, "chinese", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(Calendar, "dangi", StringComparison.OrdinalIgnoreCase);
+
+ // For era formatting, use FormatToParts to ensure proper year formatting for BC dates
+ // This is needed because .NET format strings don't handle proleptic Gregorian years correctly
+ var hasEra = Era != null;
+
+ if (isLunisolarCalendar || hasEra)
+ {
+ var parts = FormatToParts(dateTime, originalYear);
+ var sb = new StringBuilder();
+ foreach (var part in parts)
+ {
+ sb.Append(part.Value);
+ }
+ return sb.ToString();
+ }
+
+ // Convert to specified timezone if one was provided
+ if (TimeZone != null)
+ {
+ dateTime = ConvertToTimeZone(dateTime, TimeZone);
+ }
+
+ string result;
+
+ // If dateStyle or timeStyle is specified, use those
+ if (DateStyle != null || TimeStyle != null)
+ {
+ result = FormatWithStyles(dateTime);
+ }
+ else
+ {
+ // Otherwise build format from component options
+ result = FormatWithComponents(dateTime, originalYear);
+ }
+
+ // Apply numbering system transliteration if not using Latin digits
+ if (NumberingSystem != null && !string.Equals(NumberingSystem, "latn", StringComparison.OrdinalIgnoreCase))
+ {
+ result = Data.NumberingSystemData.TransliterateDigits(result, NumberingSystem);
+ }
+
+ return result;
+ }
+
+ private static DateTime ConvertToTimeZone(DateTime dateTime, string timeZoneId)
+ {
+ if (string.Equals(timeZoneId, "UTC", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(timeZoneId, "+00:00", StringComparison.Ordinal))
+ {
+ // Convert to UTC
+ if (dateTime.Kind == DateTimeKind.Local)
+ {
+ return dateTime.ToUniversalTime();
+ }
+ return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
+ }
+
+ // Check for offset timezone format like "+03:00", "-07:30"
+ var offset = TryParseOffset(timeZoneId);
+ if (offset.HasValue)
+ {
+ // Convert to UTC first
+ if (dateTime.Kind == DateTimeKind.Local)
+ {
+ dateTime = dateTime.ToUniversalTime();
+ }
+ dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
+
+ // Apply the offset
+ return dateTime.Add(offset.Value);
+ }
+
+ try
+ {
+ var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
+ if (dateTime.Kind == DateTimeKind.Local)
+ {
+ dateTime = dateTime.ToUniversalTime();
+ }
+ return TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc), timeZone);
+ }
+ catch
+ {
+ // If timezone lookup fails, return as-is
+ return dateTime;
+ }
+ }
+
+ ///
+ /// Parses an offset timezone string like "+03:00" or "-07:30" and returns the TimeSpan offset.
+ ///
+ private static TimeSpan? TryParseOffset(string timeZoneId)
+ {
+ if (string.IsNullOrEmpty(timeZoneId) || timeZoneId.Length != 6)
+ {
+ return null;
+ }
+
+ var sign = timeZoneId[0];
+ if (sign != '+' && sign != '-')
+ {
+ return null;
+ }
+
+ if (timeZoneId[3] != ':')
+ {
+ return null;
+ }
+
+ // Parse hours and minutes using direct character parsing for compatibility
+ if (!char.IsDigit(timeZoneId[1]) || !char.IsDigit(timeZoneId[2]) ||
+ !char.IsDigit(timeZoneId[4]) || !char.IsDigit(timeZoneId[5]))
+ {
+ return null;
+ }
+
+ var hours = (timeZoneId[1] - '0') * 10 + (timeZoneId[2] - '0');
+ var minutes = (timeZoneId[4] - '0') * 10 + (timeZoneId[5] - '0');
+
+ var totalMinutes = hours * 60 + minutes;
+ if (sign == '-')
+ {
+ totalMinutes = -totalMinutes;
+ }
+
+ return TimeSpan.FromMinutes(totalMinutes);
+ }
+
+ ///
+ /// Gets the era name for a date based on the calendar and style.
+ /// Returns null for calendars that don't have eras (chinese, dangi).
+ ///
+ /// The .NET DateTime (may be clamped for dates outside .NET range)
+ /// The calendar type
+ /// The era style (long, short, narrow)
+ /// The original JavaScript year (for dates outside .NET DateTime range)
+ private string? GetEraName(DateTime dateTime, string calendar, string style, int? originalYear = null)
+ {
+ // Chinese and Dangi calendars don't use eras
+ if (string.Equals(calendar, "chinese", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(calendar, "dangi", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ // Get era names from CLDR provider if available
+ var eraNames = CldrProvider.GetEraNames(Locale, style);
+
+ // Use original year for era calculation if the date was clamped
+ var effectiveYear = originalYear ?? dateTime.Year;
+
+ return calendar.ToLowerInvariant() switch
+ {
+ "gregory" or "iso8601" => GetGregorianEra(effectiveYear, style, eraNames),
+ "japanese" => GetJapaneseEra(dateTime, effectiveYear, style, eraNames),
+ "roc" => GetRocEra(effectiveYear, style, eraNames),
+ "buddhist" => GetBuddhistEra(style, eraNames),
+ "hebrew" => GetHebrewEra(style, eraNames),
+ "persian" => GetPersianEra(style, eraNames),
+ "indian" => GetIndianEra(style, eraNames),
+ "ethioaa" or "ethiopic" => GetEthiopicEra(style, eraNames),
+ "coptic" => GetCopticEra(effectiveYear, style, eraNames),
+ "islamic" or "islamic-civil" or "islamic-tbla" or "islamic-umalqura" => GetIslamicEra(style, eraNames),
+ _ => GetGregorianEra(effectiveYear, style, eraNames) // Default to Gregorian
+ };
+ }
+
+ private static string GetGregorianEra(int year, string style, string[]? eraNames)
+ {
+ var isAD = year > 0;
+ if (eraNames != null && eraNames.Length >= 2)
+ {
+ return isAD ? eraNames[1] : eraNames[0];
+ }
+ // Fallback era names
+ return style switch
+ {
+ "long" => isAD ? "Anno Domini" : "Before Christ",
+ "short" => isAD ? "AD" : "BC",
+ "narrow" => isAD ? "A" : "B",
+ _ => isAD ? "AD" : "BC"
+ };
+ }
+
+ private static string GetJapaneseEra(DateTime dateTime, int effectiveYear, string style, string[]? eraNames)
+ {
+ // Japanese era calculation
+ // Reiwa: 2019-05-01 onwards
+ // Heisei: 1989-01-08 to 2019-04-30
+ // Showa: 1926-12-25 to 1989-01-07
+ // Taisho: 1912-07-30 to 1926-12-24
+ // Meiji: 1868-01-25 to 1912-07-29
+ // Before Meiji
+
+ // Determine era index and get name based on style
+ // For Japanese eras, short and long use the same full name
+ var isNarrow = string.Equals(style, "narrow", StringComparison.Ordinal);
+
+ // Check for dates within .NET DateTime range using actual DateTime comparison
+ if (effectiveYear >= 2019 && dateTime >= new DateTime(2019, 5, 1))
+ {
+ return isNarrow ? "R" : "Reiwa";
+ }
+
+ if (effectiveYear >= 1989 && dateTime >= new DateTime(1989, 1, 8))
+ {
+ return isNarrow ? "H" : "Heisei";
+ }
+
+ if (effectiveYear >= 1926 && dateTime >= new DateTime(1926, 12, 25))
+ {
+ return isNarrow ? "S" : "Shōwa";
+ }
+
+ if (effectiveYear >= 1912 && dateTime >= new DateTime(1912, 7, 30))
+ {
+ return isNarrow ? "T" : "Taishō";
+ }
+
+ if (effectiveYear >= 1868 && dateTime >= new DateTime(1868, 1, 25))
+ {
+ return isNarrow ? "M" : "Meiji";
+ }
+
+ // Before Meiji - use Gregorian era based on the effective year
+ return effectiveYear > 0 ? "AD" : "BC";
+ }
+
+ private static string GetRocEra(int year, string style, string[]? eraNames)
+ {
+ // Republic of China calendar: year 1 = 1912 CE
+ // Note: eraNames from CLDR are Gregorian, not ROC-specific, so we use hardcoded values
+ var isAfter1912 = year >= 1912;
+ return style switch
+ {
+ "long" => isAfter1912 ? "Minguo" : "Before R.O.C.",
+ "short" => isAfter1912 ? "Minguo" : "Before R.O.C.",
+ "narrow" => isAfter1912 ? "R.O.C." : "B.R.O.C.",
+ _ => isAfter1912 ? "Minguo" : "Before R.O.C."
+ };
+ }
+
+ private static string GetBuddhistEra(string style, string[]? eraNames)
+ {
+ // Buddhist calendar has single era (BE - Buddhist Era)
+ // Note: eraNames from CLDR are Gregorian, not Buddhist-specific
+ return style switch
+ {
+ "long" => "Buddhist Era",
+ "short" => "BE",
+ "narrow" => "BE",
+ _ => "BE"
+ };
+ }
+
+ private static string GetHebrewEra(string style, string[]? eraNames)
+ {
+ // Hebrew calendar has single era (AM - Anno Mundi)
+ // Note: eraNames from CLDR are Gregorian, not Hebrew-specific
+ return style switch
+ {
+ "long" => "Anno Mundi",
+ "short" => "AM",
+ "narrow" => "AM",
+ _ => "AM"
+ };
+ }
+
+ private static string GetPersianEra(string style, string[]? eraNames)
+ {
+ // Persian calendar has single era (AP - Anno Persico)
+ // Note: eraNames from CLDR are Gregorian, not Persian-specific
+ return style switch
+ {
+ "long" => "Anno Persico",
+ "short" => "AP",
+ "narrow" => "AP",
+ _ => "AP"
+ };
+ }
+
+ private static string GetIndianEra(string style, string[]? eraNames)
+ {
+ // Indian national calendar has single era (Saka)
+ // Note: eraNames from CLDR are Gregorian, not Indian-specific
+ return style switch
+ {
+ "long" => "Saka",
+ "short" => "Saka",
+ "narrow" => "Saka",
+ _ => "Saka"
+ };
+ }
+
+ private static string GetEthiopicEra(string style, string[]? eraNames)
+ {
+ // Ethiopic calendar uses Era of the Incarnation
+ // Note: eraNames from CLDR are Gregorian, not Ethiopic-specific
+ return style switch
+ {
+ "long" => "Era of the Incarnation",
+ "short" => "ERA1",
+ "narrow" => "ERA1",
+ _ => "ERA1"
+ };
+ }
+
+ private static string GetCopticEra(int year, string style, string[]? eraNames)
+ {
+ // Coptic calendar has two eras
+ // Note: eraNames from CLDR are Gregorian, not Coptic-specific, so we use hardcoded values
+ var isAfterEpoch = year >= 284; // Year 284 CE
+ return style switch
+ {
+ "long" => isAfterEpoch ? "Era of the Martyrs" : "Before Era of the Martyrs",
+ "short" => isAfterEpoch ? "AM" : "BAM",
+ "narrow" => isAfterEpoch ? "AM" : "BAM",
+ _ => isAfterEpoch ? "AM" : "BAM"
+ };
+ }
+
+ private static string GetIslamicEra(string style, string[]? eraNames)
+ {
+ // Islamic calendar has single era (AH - Anno Hegirae)
+ // Note: eraNames from CLDR are Gregorian, not Islamic-specific
+ return style switch
+ {
+ "long" => "Anno Hegirae",
+ "short" => "AH",
+ "narrow" => "AH",
+ _ => "AH"
+ };
+ }
+
+ ///
+ /// Holds locale-specific date format information.
+ ///
+ private readonly struct LocaleDateFormatInfo
+ {
+ public LocaleDateFormatInfo(string dateOrder, string dateSeparator, bool hasTextualMonth)
+ {
+ DateOrder = dateOrder;
+ DateSeparator = dateSeparator;
+ HasTextualMonth = hasTextualMonth;
+ }
+
+ /// Date component order as "Mdy", "dMy", or "yMd".
+ public string DateOrder { get; }
+ /// Separator between date components.
+ public string DateSeparator { get; }
+ /// Whether the month is textual (long, short, narrow) vs numeric.
+ public bool HasTextualMonth { get; }
+ }
+
+ ///
+ /// Determines the locale-specific date format order and separator.
+ ///
+ private LocaleDateFormatInfo GetLocaleDateFormat()
+ {
+ // Check if month is textual (long, short, narrow) vs numeric
+ var hasTextualMonth = Month != null && Month is "long" or "short" or "narrow";
+
+ // Get locale's short date pattern to determine order
+ var lang = Locale.Split('-')[0].ToLowerInvariant();
+ var region = Locale.Contains('-') ? Locale.Split('-')[1].ToUpperInvariant() : "";
+
+ // Determine date component order based on locale
+ // MDY: en-US, en-CA (Canada uses MDY in English), fil (Philippines)
+ // DMY: Most of Europe, UK, Australia, most of world
+ // YMD: East Asia (China, Japan, Korea), Lithuania, Hungary
+ string dateOrder;
+ if (string.Equals(lang, "en", StringComparison.Ordinal) &&
+ (string.Equals(region, "US", StringComparison.Ordinal) ||
+ string.Equals(region, "", StringComparison.Ordinal)))
+ {
+ // en-US and plain "en" use MDY
+ dateOrder = "Mdy";
+ }
+ else if (string.Equals(lang, "zh", StringComparison.Ordinal) ||
+ string.Equals(lang, "ja", StringComparison.Ordinal) ||
+ string.Equals(lang, "ko", StringComparison.Ordinal) ||
+ string.Equals(lang, "hu", StringComparison.Ordinal) ||
+ string.Equals(lang, "lt", StringComparison.Ordinal))
+ {
+ // East Asian languages and Hungarian/Lithuanian use YMD
+ dateOrder = "yMd";
+ }
+ else
+ {
+ // Most of the world uses DMY
+ dateOrder = "dMy";
+ }
+
+ // Determine separator based on whether month is textual
+ string dateSeparator;
+ if (hasTextualMonth)
+ {
+ // Textual month uses space separators: "Jan 3, 2019"
+ dateSeparator = " ";
+ }
+ else
+ {
+ // Numeric format uses "/" for en-US, varies by locale
+ dateSeparator = "/";
+ }
+
+ return new LocaleDateFormatInfo(dateOrder, dateSeparator, hasTextualMonth);
+ }
+
+ private void AddMonthPart(DateTime dateTime, List result, ref bool hasDate, string separator, bool hasTextualMonth, ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null)
+ {
+ if (result.Count > 0 && hasDate)
+ {
+ result.Add(new DateTimePart("literal", separator));
+ }
+
+ string monthValue;
+ if (lunisolarDate.HasValue)
+ {
+ // Use Chinese/Dangi calendar month
+ var chineseMonth = lunisolarDate.Value.Month;
+ monthValue = Month switch
+ {
+ "numeric" => chineseMonth.ToString(CultureInfo),
+ "2-digit" => chineseMonth.ToString("D2", CultureInfo),
+ // For textual months in lunisolar calendars, we still use numeric
+ // as Chinese month names are not typically used in Intl formatting
+ "long" or "short" or "narrow" => chineseMonth.ToString(CultureInfo),
+ _ => chineseMonth.ToString("D2", CultureInfo)
+ };
+ }
+ else
+ {
+ var format = Month switch
+ {
+ "numeric" => "%M",
+ "2-digit" => "MM",
+ "long" => "MMMM",
+ "short" => "MMM",
+ "narrow" => "MMM",
+ _ => "MM"
+ };
+ monthValue = dateTime.ToString(format, CultureInfo);
+ }
+
+ result.Add(new DateTimePart("month", monthValue));
+ hasDate = true;
+ }
+
+ private void AddDayPart(DateTime dateTime, List result, ref bool hasDate, string separator, bool hasTextualMonth, ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null)
+ {
+ if (result.Count > 0 && hasDate)
+ {
+ result.Add(new DateTimePart("literal", separator));
+ }
+
+ string dayValue;
+ if (lunisolarDate.HasValue)
+ {
+ // Use Chinese/Dangi calendar day
+ var chineseDay = lunisolarDate.Value.Day;
+ dayValue = Day switch
+ {
+ "numeric" => chineseDay.ToString(CultureInfo),
+ "2-digit" => chineseDay.ToString("D2", CultureInfo),
+ _ => chineseDay.ToString("D2", CultureInfo)
+ };
+ }
+ else
+ {
+ var format = Day switch
+ {
+ "numeric" => "%d",
+ "2-digit" => "dd",
+ _ => "dd"
+ };
+ dayValue = dateTime.ToString(format, CultureInfo);
+ }
+
+ result.Add(new DateTimePart("day", dayValue));
+ hasDate = true;
+ }
+
+ private void AddYearPart(DateTime dateTime, List result, ref bool hasDate, string separator, bool hasTextualMonth, ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null, int? originalYear = null)
+ {
+ if (result.Count > 0 && hasDate)
+ {
+ // For textual month format, use ", " before year if it comes last
+ var actualSeparator = hasTextualMonth ? ", " : separator;
+ result.Add(new DateTimePart("literal", actualSeparator));
+ }
+
+ if (lunisolarDate.HasValue)
+ {
+ // For Chinese/Dangi calendars, output relatedYear and yearName instead of year
+ var relatedYear = lunisolarDate.Value.RelatedYear;
+ var yearName = lunisolarDate.Value.YearName;
+
+ // Check locale for formatting - zh locale uses "年" suffix
+ var lang = Locale.Split('-')[0].ToLowerInvariant();
+ var isChineseLocale = string.Equals(lang, "zh", StringComparison.Ordinal);
+
+ // Add relatedYear part
+ var relatedYearValue = Year switch
+ {
+ "numeric" => relatedYear.ToString(CultureInfo),
+ "2-digit" => (relatedYear % 100).ToString("00", CultureInfo),
+ _ => relatedYear.ToString(CultureInfo)
+ };
+ result.Add(new DateTimePart("relatedYear", relatedYearValue));
+
+ // Add yearName part (干支 sexagenary cycle name)
+ result.Add(new DateTimePart("yearName", yearName));
+
+ // For Chinese locale, add "年" (year) suffix
+ if (isChineseLocale)
+ {
+ result.Add(new DateTimePart("literal", "年"));
+ }
+ }
+ else
+ {
+ // Use original year if provided (for dates outside .NET DateTime range)
+ var effectiveYear = originalYear ?? dateTime.Year;
+
+ // For proleptic Gregorian calendar with era, convert negative years to positive BC years
+ // Year 0 in astronomical notation = 1 BC, year -1 = 2 BC, etc.
+ var displayYear = Era != null && effectiveYear <= 0 ? 1 - effectiveYear : System.Math.Abs(effectiveYear);
+
+ var yearValue = Year switch
+ {
+ // For numeric with era, use plain number without leading zeros
+ "numeric" => displayYear.ToString(CultureInfo),
+ "2-digit" => (displayYear % 100).ToString("00", CultureInfo),
+ _ => displayYear.ToString(CultureInfo)
+ };
+ result.Add(new DateTimePart("year", yearValue));
+ }
+ hasDate = true;
+ }
+
+ private string FormatWithStyles(DateTime dateTime)
+ {
+ // When both dateStyle and timeStyle are specified, combine them appropriately
+ if (DateStyle != null && TimeStyle != null)
+ {
+ // Format date and time separately and combine with ", "
+ var datePart = FormatDateStyleOnly(dateTime);
+ var timePart = FormatTimeStyle(dateTime);
+ return $"{datePart}, {timePart}";
+ }
+
+ if (DateStyle != null)
+ {
+ return FormatDateStyleOnly(dateTime);
+ }
+
+ if (TimeStyle != null)
+ {
+ return FormatTimeStyle(dateTime);
+ }
+
+ return dateTime.ToString("G", CultureInfo);
+ }
+
+ private string FormatDateStyleOnly(DateTime dateTime)
+ {
+ return DateStyle switch
+ {
+ "full" => dateTime.ToString("D", CultureInfo), // Full date pattern (includes weekday)
+ "long" => FormatLongDate(dateTime), // Long date without weekday
+ "medium" => FormatMediumDate(dateTime), // Medium date (same as long for most locales)
+ "short" => FormatShortDate(dateTime), // Short date (numeric)
+ _ => dateTime.ToString("d", CultureInfo)
+ };
+ }
+
+ ///
+ /// Formats a date in long style (without weekday), e.g., "May 1, 1886"
+ ///
+ private string FormatLongDate(DateTime dateTime)
+ {
+ // Use MMMM d, yyyy for en-US style, or locale-appropriate pattern
+ var lang = Locale.Split('-')[0].ToLowerInvariant();
+ if (string.Equals(lang, "en", StringComparison.Ordinal))
+ {
+ return dateTime.ToString("MMMM d, yyyy", CultureInfo);
+ }
+ // For other locales, use the long date pattern without weekday
+ var longPattern = CultureInfo.DateTimeFormat.LongDatePattern;
+ // Remove weekday-related format specifiers manually
+ var modifiedPattern = RemoveWeekdayFromPattern(longPattern);
+ if (string.IsNullOrEmpty(modifiedPattern))
+ {
+ return dateTime.ToString("MMMM d, yyyy", CultureInfo);
+ }
+ return dateTime.ToString(modifiedPattern, CultureInfo);
+ }
+
+ private static string RemoveWeekdayFromPattern(string pattern)
+ {
+ // Remove dddd or ddd followed by optional comma/space
+ var result = pattern;
+ var weekdayPatterns = new[] { "dddd, ", "dddd,", "dddd ", "dddd", "ddd, ", "ddd,", "ddd ", "ddd" };
+ foreach (var wp in weekdayPatterns)
+ {
+ var idx = result.IndexOf(wp, StringComparison.Ordinal);
+ if (idx >= 0)
+ {
+ result = result.Remove(idx, wp.Length);
+ break;
+ }
+ }
+ return result.Trim().TrimStart(',').Trim();
+ }
+
+ ///
+ /// Formats a date in medium style, e.g., "May 1, 1886"
+ ///
+ private string FormatMediumDate(DateTime dateTime)
+ {
+ // Medium is typically the same as long for most locales
+ return FormatLongDate(dateTime);
+ }
+
+ ///
+ /// Formats a date in short style, e.g., "5/1/86"
+ ///
+ private string FormatShortDate(DateTime dateTime)
+ {
+ // Use locale's short date pattern but with 2-digit year
+ var lang = Locale.Split('-')[0].ToLowerInvariant();
+ if (string.Equals(lang, "en", StringComparison.Ordinal))
+ {
+ // US style: M/d/yy with literal slash separator
+ return dateTime.ToString("M'/'d'/'yy", CultureInfo);
+ }
+ // For other locales, use the short date pattern
+ return dateTime.ToString("d", CultureInfo);
+ }
+
+ ///
+ /// Formats time using timeStyle, respecting hourCycle
+ ///
+ private string FormatTimeStyle(DateTime dateTime)
+ {
+ // Determine if we should use 12 or 24 hour format
+ bool use12Hour;
+ if (HourCycle != null)
+ {
+ // Explicit hourCycle takes precedence
+ use12Hour = string.Equals(HourCycle, "h11", StringComparison.Ordinal) ||
+ string.Equals(HourCycle, "h12", StringComparison.Ordinal);
+ }
+ else
+ {
+ // Derive from locale - English uses 12-hour, most others use 24-hour
+ var lang = Locale.Split('-')[0].ToLowerInvariant();
+ use12Hour = string.Equals(lang, "en", StringComparison.Ordinal);
+ }
+
+ var timeZoneSuffix = "";
+ if (string.Equals(TimeStyle, "full", StringComparison.Ordinal))
+ {
+ if (!string.IsNullOrEmpty(TimeZone) && string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase))
+ {
+ timeZoneSuffix = " Coordinated Universal Time";
+ }
+ else
+ {
+ timeZoneSuffix = " " + TimeZoneInfo.Local.DisplayName;
+ }
+ }
+ else if (string.Equals(TimeStyle, "long", StringComparison.Ordinal))
+ {
+ if (!string.IsNullOrEmpty(TimeZone) && string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase))
+ {
+ timeZoneSuffix = " UTC";
+ }
+ }
+
+ return TimeStyle switch
+ {
+ "full" => use12Hour
+ ? dateTime.ToString("h:mm:ss", CultureInfo) + " " + GetDayPeriod(dateTime.Hour) + timeZoneSuffix
+ : dateTime.ToString("HH:mm:ss", CultureInfo) + timeZoneSuffix,
+ "long" => use12Hour
+ ? dateTime.ToString("h:mm:ss", CultureInfo) + " " + GetDayPeriod(dateTime.Hour) + timeZoneSuffix
+ : dateTime.ToString("HH:mm:ss", CultureInfo) + timeZoneSuffix,
+ "medium" => use12Hour
+ ? dateTime.ToString("h:mm:ss", CultureInfo) + " " + GetDayPeriod(dateTime.Hour)
+ : dateTime.ToString("HH:mm:ss", CultureInfo),
+ "short" => use12Hour
+ ? dateTime.ToString("h:mm", CultureInfo) + " " + GetDayPeriod(dateTime.Hour)
+ : dateTime.ToString("HH:mm", CultureInfo),
+ _ => use12Hour
+ ? dateTime.ToString("h:mm", CultureInfo) + " " + GetDayPeriod(dateTime.Hour)
+ : dateTime.ToString("HH:mm", CultureInfo),
+ };
+ }
+
+ private static string GetDayPeriod(int hour)
+ {
+ return hour < 12 ? "AM" : "PM";
+ }
+
+ private string FormatWithComponents(DateTime dateTime, int? originalYear = null)
+ {
+ // Build a custom format string based on component options
+ var parts = new List();
+ string? eraValue = null;
+
+ // Get locale-specific date format info
+ var formatInfo = GetLocaleDateFormat();
+
+ // Weekday
+ if (Weekday != null)
+ {
+ parts.Add(Weekday switch
+ {
+ "long" => "dddd",
+ "short" => "ddd",
+ "narrow" => "ddd",
+ _ => "ddd"
+ });
+ }
+
+ // Era - get the era name but add it after formatting (since .NET doesn't support custom eras)
+ if (Era != null)
+ {
+ eraValue = GetEraName(dateTime, Calendar ?? "gregory", Era, originalYear);
+ }
+
+ // Add date parts in locale-specific order
+ foreach (var component in formatInfo.DateOrder)
+ {
+ switch (component)
+ {
+ case 'M' when Month != null:
+ parts.Add(Month switch
+ {
+ "numeric" => "M",
+ "2-digit" => "MM",
+ "long" => "MMMM",
+ "short" => "MMM",
+ "narrow" => "MMM",
+ _ => "MM"
+ });
+ break;
+ case 'd' when Day != null:
+ parts.Add(Day switch
+ {
+ "numeric" => "d",
+ "2-digit" => "dd",
+ _ => "dd"
+ });
+ break;
+ case 'y' when Year != null:
+ parts.Add(Year switch
+ {
+ "numeric" => "yyyy",
+ "2-digit" => "yy",
+ _ => "yyyy"
+ });
+ break;
+ }
+ }
+
+ // Hour
+ if (Hour != null)
+ {
+ var use12Hour = string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal);
+ parts.Add(Hour switch
+ {
+ "numeric" => use12Hour ? "h" : "H",
+ "2-digit" => use12Hour ? "hh" : "HH",
+ _ => use12Hour ? "h" : "H"
+ });
+ }
+
+ // Minute - for time components, "numeric" typically uses 2-digit padding in most locales
+ if (Minute != null)
+ {
+ parts.Add(Minute switch
+ {
+ "numeric" => "mm",
+ "2-digit" => "mm",
+ _ => "mm"
+ });
+ }
+
+ // Second - for time components, "numeric" typically uses 2-digit padding in most locales
+ if (Second != null)
+ {
+ parts.Add(Second switch
+ {
+ "numeric" => "ss",
+ "2-digit" => "ss",
+ _ => "ss"
+ });
+ }
+
+ // Fractional seconds
+ if (FractionalSecondDigits.HasValue && FractionalSecondDigits.Value > 0)
+ {
+ parts.Add(new string('f', FractionalSecondDigits.Value));
+ }
+
+ // Day period (AM/PM) - only add "tt" if using 12-hour format with hour specified
+ // and DayPeriod is not explicitly specified (DayPeriod uses extended periods)
+ var needsAmPm = Hour != null && string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal) && DayPeriod == null;
+ if (needsAmPm)
+ {
+ parts.Add("tt");
+ }
+
+ // Time zone name
+ if (TimeZoneName != null)
+ {
+ parts.Add(TimeZoneName switch
+ {
+ "long" => "zzz",
+ "short" => "zzz",
+ "shortOffset" => "zzz",
+ "longOffset" => "zzz",
+ "shortGeneric" => "zzz",
+ "longGeneric" => "zzz",
+ _ => "zzz"
+ });
+ }
+
+ // Handle DayPeriod option (extended day periods like "in the morning")
+ if (DayPeriod != null)
+ {
+ // If only dayPeriod is specified (no other components), just return the day period
+ if (parts.Count == 0 && eraValue == null)
+ {
+ return GetExtendedDayPeriod(dateTime.Hour);
+ }
+
+ // Otherwise, format with other components and append day period
+ string formatted;
+ if (parts.Count > 0)
+ {
+ var formatString = BuildFormatString(parts);
+ formatted = dateTime.ToString(formatString, CultureInfo);
+ }
+ else
+ {
+ formatted = "";
+ }
+
+ // Append era if specified
+ if (eraValue != null)
+ {
+ if (formatted.Length > 0)
+ {
+ formatted += " " + eraValue;
+ }
+ else
+ {
+ formatted = eraValue;
+ }
+ }
+
+ return formatted + " " + GetExtendedDayPeriod(dateTime.Hour);
+ }
+
+ if (parts.Count == 0 && eraValue == null)
+ {
+ // Default format if no components specified
+ return dateTime.ToString("G", CultureInfo);
+ }
+
+ // Join parts with appropriate separators
+ string result;
+ if (parts.Count > 0)
+ {
+ var formatString2 = BuildFormatString(parts);
+ result = dateTime.ToString(formatString2, CultureInfo);
+ }
+ else
+ {
+ result = "";
+ }
+
+ // Append era if specified
+ if (eraValue != null)
+ {
+ if (result.Length > 0)
+ {
+ result += " " + eraValue;
+ }
+ else
+ {
+ result = eraValue;
+ }
+ }
+
+ return result;
+ }
+
+ private string GetHourFormat()
+ {
+ if (HourCycle != null)
+ {
+ if (string.Equals(HourCycle, "h11", StringComparison.Ordinal) ||
+ string.Equals(HourCycle, "h12", StringComparison.Ordinal))
+ {
+ return "h12";
+ }
+ if (string.Equals(HourCycle, "h23", StringComparison.Ordinal) ||
+ string.Equals(HourCycle, "h24", StringComparison.Ordinal))
+ {
+ return "h24";
+ }
+ return "h12";
+ }
+
+ // Default based on locale's short time pattern
+ // If pattern contains uppercase H, locale uses 24-hour; lowercase h means 12-hour
+ var timePattern = CultureInfo.DateTimeFormat.ShortTimePattern;
+ return timePattern.Contains('H') ? "h24" : "h12";
+ }
+
+ private string BuildFormatString(List parts)
+ {
+ // Simple join - a more sophisticated implementation would use
+ // locale-specific patterns
+ var result = new ValueStringBuilder();
+ var hasDate = false;
+ var hasTime = false;
+
+ // Check if this format uses a textual month (affects separator choice)
+ var hasTextualMonth = Month is "short" or "long" or "narrow";
+
+ foreach (var part in parts)
+ {
+ if (part.Length == 0)
+ {
+ continue;
+ }
+
+ var firstChar = part[0];
+
+ if (result.Length > 0)
+ {
+ // Add separator based on what we're joining
+ if (firstChar is 'h' or 'H' or 'm' or 's' or 'f' or 't')
+ {
+ if (!hasTime)
+ {
+ if (hasDate)
+ {
+ result.Append(' ');
+ }
+ hasTime = true;
+ }
+ else if (firstChar is not 't' and not 'f')
+ {
+ result.Append(':');
+ }
+ else if (firstChar == 't')
+ {
+ result.Append(' ');
+ }
+ else if (firstChar == 'f')
+ {
+ result.Append('.');
+ }
+ }
+ else if (firstChar == 'z')
+ {
+ result.Append(' ');
+ }
+ else
+ {
+ if (!hasDate)
+ {
+ hasDate = true;
+ }
+ else
+ {
+ // Use appropriate separator based on format type
+ if (hasTextualMonth)
+ {
+ // Textual month format: "Jan 3, 2019"
+ // Use space after month, comma-space before year
+ if (firstChar is 'y' or 'Y')
+ {
+ result.Append("', '"); // Literal ", " before year
+ }
+ else
+ {
+ result.Append(' '); // Space between other parts
+ }
+ }
+ else
+ {
+ // Numeric format: "1/3/2019"
+ // Use literal '/' by escaping with single quotes to avoid .NET's culture-specific date separator
+ result.Append("'/'");
+ }
+ }
+ }
+ }
+ else
+ {
+ if (firstChar is 'h' or 'H' or 'm' or 's' or 'f')
+ {
+ hasTime = true;
+ }
+ else if (firstChar is not 't' and not 'z')
+ {
+ hasDate = true;
+ }
+ }
+
+ result.Append(part);
+ }
+
+ var formatString = result.ToString();
+
+ // In .NET, single character format strings are interpreted as standard format specifiers
+ // We need to prefix with % to indicate it's a custom format
+ if (formatString.Length == 1)
+ {
+ return "%" + formatString;
+ }
+
+ return formatString;
+ }
+
+ ///
+ /// Returns the formatted parts with their types for formatToParts.
+ ///
+ /// The .NET DateTime to format
+ /// Optional original JavaScript year (for dates outside .NET DateTime range)
+ internal List FormatToParts(DateTime dateTime, int? originalYear = null)
+ {
+ // Convert to specified timezone if one was provided
+ if (TimeZone != null)
+ {
+ dateTime = ConvertToTimeZone(dateTime, TimeZone);
+ }
+
+ var result = new List();
+
+ if (DateStyle != null || TimeStyle != null)
+ {
+ // For style-based formatting, use a simpler approach
+ FormatStyleToParts(dateTime, result, originalYear);
+ }
+ else
+ {
+ FormatComponentsToParts(dateTime, result, originalYear);
+ }
+
+ // Apply numbering system transliteration if not using Latin digits
+ if (NumberingSystem != null && !string.Equals(NumberingSystem, "latn", StringComparison.OrdinalIgnoreCase))
+ {
+ for (var i = 0; i < result.Count; i++)
+ {
+ var part = result[i];
+ var transliterated = Data.NumberingSystemData.TransliterateDigits(part.Value, NumberingSystem);
+ if (!ReferenceEquals(transliterated, part.Value))
+ {
+ result[i] = new DateTimePart(part.Type, transliterated);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private void FormatStyleToParts(DateTime dateTime, List result, int? originalYear)
+ {
+ // For style-based formatting, decompose into proper parts
+ // Map styles to component options and use component-based parts generation
+ var hasDate = DateStyle != null;
+ var hasTime = TimeStyle != null;
+
+ if (hasDate)
+ {
+ FormatDateStyleToParts(dateTime, result);
+ }
+
+ if (hasDate && hasTime)
+ {
+ // Add separator between date and time
+ result.Add(new DateTimePart("literal", ", "));
+ }
+
+ if (hasTime)
+ {
+ FormatTimeStyleToParts(dateTime, result);
+ }
+ }
+
+ private void FormatDateStyleToParts(DateTime dateTime, List result)
+ {
+ var style = DateStyle;
+
+ // Check if using Chinese or Dangi calendar
+ var isChineseCalendar = string.Equals(Calendar, "chinese", StringComparison.OrdinalIgnoreCase);
+ var isDangiCalendar = string.Equals(Calendar, "dangi", StringComparison.OrdinalIgnoreCase);
+ var isLunisolarCalendar = isChineseCalendar || isDangiCalendar;
+
+ // Get Chinese/Dangi calendar date if needed
+ ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null;
+ if (isLunisolarCalendar)
+ {
+ lunisolarDate = isChineseCalendar
+ ? ChineseCalendarHelper.GetChineseDate(dateTime)
+ : ChineseCalendarHelper.GetDangiDate(dateTime);
+ }
+
+ // Full: weekday, month, day, year
+ // Long: month, day, year
+ // Medium: month, day, year (abbreviated)
+ // Short: month/day/year (numeric)
+
+ if (string.Equals(style, "full", StringComparison.Ordinal))
+ {
+ result.Add(new DateTimePart("weekday", dateTime.ToString("dddd", CultureInfo)));
+ result.Add(new DateTimePart("literal", ", "));
+ }
+
+ if (string.Equals(style, "full", StringComparison.Ordinal) ||
+ string.Equals(style, "long", StringComparison.Ordinal))
+ {
+ if (lunisolarDate.HasValue)
+ {
+ AddLunisolarDateParts(result, lunisolarDate.Value, textualMonth: true);
+ }
+ else
+ {
+ result.Add(new DateTimePart("month", dateTime.ToString("MMMM", CultureInfo)));
+ result.Add(new DateTimePart("literal", " "));
+ result.Add(new DateTimePart("day", dateTime.Day.ToString(CultureInfo)));
+ result.Add(new DateTimePart("literal", ", "));
+ result.Add(new DateTimePart("year", dateTime.Year.ToString(CultureInfo)));
+ }
+ }
+ else if (string.Equals(style, "medium", StringComparison.Ordinal))
+ {
+ if (lunisolarDate.HasValue)
+ {
+ AddLunisolarDateParts(result, lunisolarDate.Value, textualMonth: false);
+ }
+ else
+ {
+ result.Add(new DateTimePart("month", dateTime.ToString("MMM", CultureInfo)));
+ result.Add(new DateTimePart("literal", " "));
+ result.Add(new DateTimePart("day", dateTime.Day.ToString(CultureInfo)));
+ result.Add(new DateTimePart("literal", ", "));
+ result.Add(new DateTimePart("year", dateTime.Year.ToString(CultureInfo)));
+ }
+ }
+ else // short
+ {
+ if (lunisolarDate.HasValue)
+ {
+ AddLunisolarDateParts(result, lunisolarDate.Value, textualMonth: false, shortFormat: true);
+ }
+ else
+ {
+ result.Add(new DateTimePart("month", dateTime.Month.ToString(CultureInfo)));
+ result.Add(new DateTimePart("literal", "/"));
+ result.Add(new DateTimePart("day", dateTime.Day.ToString(CultureInfo)));
+ result.Add(new DateTimePart("literal", "/"));
+ result.Add(new DateTimePart("year", (dateTime.Year % 100).ToString("D2", CultureInfo)));
+ }
+ }
+ }
+
+ ///
+ /// Adds date parts for Chinese/Dangi lunisolar calendars.
+ ///
+ private void AddLunisolarDateParts(List result, ChineseCalendarHelper.ChineseCalendarDate date, bool textualMonth, bool shortFormat = false)
+ {
+ var lang = Locale.Split('-')[0].ToLowerInvariant();
+ var isChineseLocale = string.Equals(lang, "zh", StringComparison.Ordinal);
+
+ // Month
+ result.Add(new DateTimePart("month", date.Month.ToString(CultureInfo)));
+ result.Add(new DateTimePart("literal", "/"));
+
+ // Day
+ result.Add(new DateTimePart("day", date.Day.ToString(CultureInfo)));
+ result.Add(new DateTimePart("literal", "/"));
+
+ // Year - use relatedYear and yearName for lunisolar calendars
+ if (shortFormat)
+ {
+ result.Add(new DateTimePart("relatedYear", (date.RelatedYear % 100).ToString("D2", CultureInfo)));
+ }
+ else
+ {
+ result.Add(new DateTimePart("relatedYear", date.RelatedYear.ToString(CultureInfo)));
+ }
+
+ // Add yearName for Chinese locale
+ if (isChineseLocale && !shortFormat)
+ {
+ result.Add(new DateTimePart("yearName", date.YearName));
+ result.Add(new DateTimePart("literal", "年"));
+ }
+ }
+
+ private void FormatTimeStyleToParts(DateTime dateTime, List result)
+ {
+ var style = TimeStyle;
+ var use12Hour = IsUsing12HourFormat();
+ var hour = use12Hour ? (dateTime.Hour % 12 == 0 ? 12 : dateTime.Hour % 12) : dateTime.Hour;
+
+ // Hour
+ result.Add(new DateTimePart("hour", hour.ToString(CultureInfo)));
+
+ // Minute (always for time styles)
+ result.Add(new DateTimePart("literal", ":"));
+ result.Add(new DateTimePart("minute", dateTime.Minute.ToString("D2", CultureInfo)));
+
+ // Second (for medium, long, full)
+ if (!string.Equals(style, "short", StringComparison.Ordinal))
+ {
+ result.Add(new DateTimePart("literal", ":"));
+ result.Add(new DateTimePart("second", dateTime.Second.ToString("D2", CultureInfo)));
+ }
+
+ // Day period (AM/PM) for 12-hour format
+ if (use12Hour)
+ {
+ result.Add(new DateTimePart("literal", " "));
+ result.Add(new DateTimePart("dayPeriod", dateTime.Hour < 12 ? "AM" : "PM"));
+ }
+
+ // Time zone name (for long and full)
+ if (string.Equals(style, "full", StringComparison.Ordinal))
+ {
+ result.Add(new DateTimePart("literal", " "));
+ result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(true)));
+ }
+ else if (string.Equals(style, "long", StringComparison.Ordinal))
+ {
+ result.Add(new DateTimePart("literal", " "));
+ result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(false)));
+ }
+ }
+
+ private bool IsUsing12HourFormat()
+ {
+ // Check hourCycle first
+ if (HourCycle != null)
+ {
+ return string.Equals(HourCycle, "h11", StringComparison.Ordinal) ||
+ string.Equals(HourCycle, "h12", StringComparison.Ordinal);
+ }
+
+ // Default based on locale - US uses 12-hour, most others use 24-hour
+ var lang = Locale.Split('-')[0].ToLowerInvariant();
+ return string.Equals(lang, "en", StringComparison.Ordinal);
+ }
+
+ private string GetTimeZoneDisplayName(bool longName)
+ {
+ if (TimeZone != null)
+ {
+ if (string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase))
+ {
+ return longName ? "Coordinated Universal Time" : "UTC";
+ }
+ // For other timezones, use the ID or a short form
+ var parts = TimeZone.Split('/');
+ return longName ? TimeZone : parts[parts.Length - 1];
+ }
+ return longName ? TimeZoneInfo.Local.DisplayName : TimeZoneInfo.Local.Id;
+ }
+
+ private void FormatComponentsToParts(DateTime dateTime, List result, int? originalYear = null)
+ {
+ var hasDate = false;
+ var hasTime = false;
+
+ // Check if using Chinese or Dangi calendar
+ var isChineseCalendar = string.Equals(Calendar, "chinese", StringComparison.OrdinalIgnoreCase);
+ var isDangiCalendar = string.Equals(Calendar, "dangi", StringComparison.OrdinalIgnoreCase);
+ var isLunisolarCalendar = isChineseCalendar || isDangiCalendar;
+
+ // Get Chinese/Dangi calendar date if needed
+ ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null;
+ if (isLunisolarCalendar)
+ {
+ lunisolarDate = isChineseCalendar
+ ? ChineseCalendarHelper.GetChineseDate(dateTime)
+ : ChineseCalendarHelper.GetDangiDate(dateTime);
+ }
+
+ // Determine locale-specific date order and separators
+ var formatInfo = GetLocaleDateFormat();
+ var dateOrder = formatInfo.DateOrder;
+ var dateSeparator = formatInfo.DateSeparator;
+ var hasTextualMonth = formatInfo.HasTextualMonth;
+
+ // Weekday (first, if present)
+ if (Weekday != null)
+ {
+ var format = Weekday switch
+ {
+ "long" => "dddd",
+ "short" => "ddd",
+ "narrow" => "ddd",
+ _ => "ddd"
+ };
+ result.Add(new DateTimePart("weekday", dateTime.ToString(format, CultureInfo)));
+ hasDate = true;
+ }
+
+ // Add date components in locale-specific order
+ foreach (var component in dateOrder)
+ {
+ switch (component)
+ {
+ case 'M' when Month != null:
+ AddMonthPart(dateTime, result, ref hasDate, dateSeparator, hasTextualMonth, lunisolarDate);
+ break;
+ case 'd' when Day != null:
+ AddDayPart(dateTime, result, ref hasDate, dateSeparator, hasTextualMonth, lunisolarDate);
+ break;
+ case 'y' when Year != null:
+ AddYearPart(dateTime, result, ref hasDate, dateSeparator, hasTextualMonth, lunisolarDate, originalYear);
+ break;
+ }
+ }
+
+ // Era (after date components)
+ if (Era != null)
+ {
+ var eraName = GetEraName(dateTime, Calendar ?? "gregory", Era, originalYear);
+ if (eraName != null)
+ {
+ if (result.Count > 0)
+ {
+ result.Add(new DateTimePart("literal", " "));
+ }
+ result.Add(new DateTimePart("era", eraName));
+ }
+ }
+
+ // Hour
+ if (Hour != null)
+ {
+ if (result.Count > 0)
+ {
+ result.Add(new DateTimePart("literal", hasDate ? ", " : ""));
+ }
+ var use12Hour = string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal);
+ var format = Hour switch
+ {
+ "numeric" => use12Hour ? "%h" : "%H", // Use % for single character
+ "2-digit" => use12Hour ? "hh" : "HH",
+ _ => use12Hour ? "%h" : "%H"
+ };
+ result.Add(new DateTimePart("hour", dateTime.ToString(format, CultureInfo)));
+ hasTime = true;
+ }
+
+ // Minute - for time components, "numeric" typically uses 2-digit padding in most locales
+ if (Minute != null)
+ {
+ if (result.Count > 0 && hasTime)
+ {
+ result.Add(new DateTimePart("literal", ":"));
+ }
+ // Per ECMA-402, minute and second use 2-digit format for both "numeric" and "2-digit"
+ result.Add(new DateTimePart("minute", dateTime.Minute.ToString("D2", CultureInfo)));
+ hasTime = true;
+ }
+
+ // Second - for time components, "numeric" typically uses 2-digit padding in most locales
+ if (Second != null)
+ {
+ if (result.Count > 0 && hasTime)
+ {
+ result.Add(new DateTimePart("literal", ":"));
+ }
+ // Per ECMA-402, minute and second use 2-digit format for both "numeric" and "2-digit"
+ result.Add(new DateTimePart("second", dateTime.Second.ToString("D2", CultureInfo)));
+ hasTime = true;
+ }
+
+ // Fractional seconds
+ if (FractionalSecondDigits.HasValue && FractionalSecondDigits.Value > 0)
+ {
+ // Use the decimal separator for the numbering system (e.g., ٫ for Arabic)
+ var decimalSeparator = NumberingSystem != null
+ ? Data.NumberingSystemData.GetDecimalSeparator(NumberingSystem).ToString()
+ : ".";
+ result.Add(new DateTimePart("literal", decimalSeparator));
+ // Use % prefix for single-character format to prevent it being interpreted as standard format
+ var format = FractionalSecondDigits.Value == 1 ? "%f" : new string('f', FractionalSecondDigits.Value);
+ result.Add(new DateTimePart("fractionalSecond", dateTime.ToString(format, CultureInfo)));
+ }
+
+ // Day period (AM/PM or extended day periods)
+ if (DayPeriod != null)
+ {
+ // Extended day periods like "in the morning", "noon", etc.
+ if (result.Count > 0)
+ {
+ result.Add(new DateTimePart("literal", " "));
+ }
+ result.Add(new DateTimePart("dayPeriod", GetExtendedDayPeriod(dateTime.Hour)));
+ }
+ else if (Hour != null && string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal))
+ {
+ result.Add(new DateTimePart("literal", " "));
+ result.Add(new DateTimePart("dayPeriod", dateTime.ToString("tt", CultureInfo)));
+ }
+
+ // Time zone name
+ if (TimeZoneName != null)
+ {
+ result.Add(new DateTimePart("literal", " "));
+ result.Add(new DateTimePart("timeZoneName", dateTime.ToString("zzz", CultureInfo)));
+ }
+
+ // If no parts were added, use default format
+ if (result.Count == 0)
+ {
+ var formatted = dateTime.ToString("G", CultureInfo);
+ result.Add(new DateTimePart("literal", formatted));
+ }
+ }
+
+ ///
+ /// Gets the extended day period string based on the hour and dayPeriod style.
+ /// CLDR defines: night1 (21:00-05:59), morning1 (06:00-11:59), noon (12:00),
+ /// afternoon1 (12:01-17:59), evening1 (18:00-20:59)
+ ///
+ private string GetExtendedDayPeriod(int hour)
+ {
+ // For English locale (en), use CLDR day period names
+ // Other locales would need locale-specific data
+ var lang = Locale.Split('-')[0];
+
+ if (string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase))
+ {
+ return DayPeriod switch
+ {
+ "long" => hour switch
+ {
+ >= 0 and < 6 => "at night",
+ >= 6 and < 12 => "in the morning",
+ 12 => "noon",
+ > 12 and < 18 => "in the afternoon",
+ >= 18 and < 21 => "in the evening",
+ _ => "at night"
+ },
+ "short" => hour switch
+ {
+ >= 0 and < 6 => "at night",
+ >= 6 and < 12 => "in the morning",
+ 12 => "noon",
+ > 12 and < 18 => "in the afternoon",
+ >= 18 and < 21 => "in the evening",
+ _ => "at night"
+ },
+ "narrow" => hour switch
+ {
+ >= 0 and < 6 => "at night",
+ >= 6 and < 12 => "in the morning",
+ 12 => "n",
+ > 12 and < 18 => "in the afternoon",
+ >= 18 and < 21 => "in the evening",
+ _ => "at night"
+ },
+ _ => hour < 12 ? "AM" : "PM"
+ };
+ }
+
+ // Default: use AM/PM
+ return hour < 12 ? "AM" : "PM";
+ }
+
+ internal readonly struct DateTimePart
+ {
+ public DateTimePart(string type, string value)
+ {
+ Type = type;
+ Value = value;
+ }
+
+ public string Type { get; }
+ public string Value { get; }
+ }
+}
diff --git a/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs b/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs
index cffbf93795..83c58485dd 100644
--- a/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs
+++ b/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs
@@ -1447,6 +1447,13 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
return JsString.Empty;
}
+ // Per ECMA-402, pass locales and options to element's toLocaleString
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+ var invokeArgs = !locales.IsUndefined() || !options.IsUndefined()
+ ? new[] { locales, options }
+ : System.Array.Empty();
+
using var r = new ValueStringBuilder();
for (uint k = 0; k < len; k++)
{
@@ -1456,7 +1463,7 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
}
if (array.TryGetValue(k, out var nextElement) && !nextElement.IsNullOrUndefined())
{
- var s = TypeConverter.ToString(Invoke(nextElement, "toLocaleString", []));
+ var s = TypeConverter.ToString(Invoke(nextElement, "toLocaleString", invokeArgs));
r.Append(s);
}
}