You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@freemarker.apache.org by dd...@apache.org on 2022/01/02 22:47:05 UTC

[freemarker] branch FREEMARKER-35 updated (3d53538 -> 691dfab)

This is an automated email from the ASF dual-hosted git repository.

ddekany pushed a change to branch FREEMARKER-35
in repository https://gitbox.apache.org/repos/asf/freemarker.git.


    from 3d53538  [FREEMARKER-35] Made temporal format related tests more reliable across Java versions. Turned off standalone month bug workaround starting from Java 9 (as it was fixed there before the first production release).
     new cbdb2e4  [FREEMARKER-35] Deleted now unused classes.
     new fffb611  [FREEMARKER-35] Added date-time parsing to TemplateTemporalFormat interface, and to JavaTemplateTemporalFormat for now. Removed feature where OffsetTime formatting without showing the offset was possible if the timeZone had no daylight saving, as it made it too likely that an application that worked one day suddenly starts to fail. Also removed the logic where the OffsetTime format style was automatically incremented to include the zone (was especially confusing combined [...]
     new 691dfab  [FREEMARKER-35] Continued temporal parsing, improved ISO (and XS) formatters. Some code cleanup.

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 src/main/java/freemarker/core/Configurable.java    |   3 +-
 src/main/java/freemarker/core/Environment.java     |  10 +-
 .../ISOLikeTemplateTemporalTemporalFormat.java     |  77 +++-
 .../core/ISOTemplateTemporalFormatFactory.java     | 172 +++++--
 .../core/JavaTemplateTemporalFormat.java           | 184 +++++---
 .../freemarker/core/TemplateTemporalFormat.java    |  14 +-
 .../core/ToStringTemplateTemporalFormat.java       |  70 ---
 .../ToStringTemplateTemporalFormatFactory.java     |  49 --
 .../core/XSTemplateTemporalFormatFactory.java      |  35 +-
 .../java/freemarker/core/_CoreTemporalUtils.java   | 124 -----
 src/main/java/freemarker/template/Template.java    |   2 +-
 .../java/freemarker/template/utility/DateUtil.java | 328 +-------------
 .../freemarker/template/utility/StringUtil.java    |  10 +-
 .../freemarker/template/utility/TemporalUtils.java | 499 +++++++++++++++++++++
 .../core/AbstractTemporalFormatTest.java           | 131 ++++++
 ...pochMillisDivTemplateTemporalFormatFactory.java |   5 +
 .../EpochMillisTemplateTemporalFormatFactory.java  |   5 +
 .../core/HTMLISOTemplateTemporalFormatFactory.java |   5 +
 ...ndTZSensitiveTemplateTemporalFormatFactory.java |   5 +
 ...ava => TemporalFormatWithCustomFormatTest.java} |   4 +-
 .../core/TemporalFormatWithIsoFormatTest.java      | 313 +++++++++++++
 ....java => TemporalFormatWithJavaFormatTest.java} | 203 ++++++---
 .../utility/DateUtilsPatternParsingTest.java       |  57 ++-
 .../utility/TemporalUtilsTest.java}                |  25 +-
 .../test/templatesuite/templates/temporal.ftl      |  11 +-
 25 files changed, 1550 insertions(+), 791 deletions(-)
 delete mode 100644 src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java
 delete mode 100644 src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java
 delete mode 100644 src/main/java/freemarker/core/_CoreTemporalUtils.java
 create mode 100644 src/main/java/freemarker/template/utility/TemporalUtils.java
 create mode 100644 src/test/java/freemarker/core/AbstractTemporalFormatTest.java
 rename src/test/java/freemarker/core/{TemporalFormatTest2.java => TemporalFormatWithCustomFormatTest.java} (93%)
 create mode 100644 src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java
 rename src/test/java/freemarker/core/{TemporalFormatTest.java => TemporalFormatWithJavaFormatTest.java} (50%)
 rename src/test/java/freemarker/{core/CoreTemporalUtilTest.java => template/utility/TemporalUtilsTest.java} (66%)

[freemarker] 02/03: [FREEMARKER-35] Added date-time parsing to TemplateTemporalFormat interface, and to JavaTemplateTemporalFormat for now. Removed feature where OffsetTime formatting without showing the offset was possible if the timeZone had no daylight saving, as it made it too likely that an application that worked one day suddenly starts to fail. Also removed the logic where the OffsetTime format style was automatically incremented to include the zone (was especially confusing combined with parsing).

Posted by dd...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ddekany pushed a commit to branch FREEMARKER-35
in repository https://gitbox.apache.org/repos/asf/freemarker.git

commit fffb61112716c048827cc72aab0a7711cb94c723
Author: ddekany <dd...@apache.org>
AuthorDate: Thu Dec 30 08:31:02 2021 +0100

    [FREEMARKER-35] Added date-time parsing to TemplateTemporalFormat interface, and to JavaTemplateTemporalFormat for now. Removed feature where OffsetTime formatting without showing the offset was possible if the timeZone had no daylight saving, as it made it too likely that an application that worked one day suddenly starts to fail. Also removed the logic where the OffsetTime format style was automatically incremented to include the zone (was especially confusing combined with parsing).
---
 src/main/java/freemarker/core/Environment.java     |   2 +-
 .../ISOLikeTemplateTemporalTemporalFormat.java     |   5 +
 .../core/JavaTemplateTemporalFormat.java           | 202 ++++++++++++++-------
 .../freemarker/core/TemplateTemporalFormat.java    |  14 +-
 ...pochMillisDivTemplateTemporalFormatFactory.java |   5 +
 .../EpochMillisTemplateTemporalFormatFactory.java  |   5 +
 .../core/HTMLISOTemplateTemporalFormatFactory.java |   5 +
 ...ndTZSensitiveTemplateTemporalFormatFactory.java |   5 +
 .../java/freemarker/core/TemporalFormatTest.java   | 174 +++++++++++++++---
 .../test/templatesuite/templates/temporal.ftl      |  11 +-
 10 files changed, 327 insertions(+), 101 deletions(-)

diff --git a/src/main/java/freemarker/core/Environment.java b/src/main/java/freemarker/core/Environment.java
index 2046396..24a0137 100644
--- a/src/main/java/freemarker/core/Environment.java
+++ b/src/main/java/freemarker/core/Environment.java
@@ -2330,7 +2330,7 @@ public final class Environment extends Configurable {
     }
 
     /**
-     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to moer informative
+     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to more informative
      * {@link TemplateException}-s.
      */
     TemplateTemporalFormat getTemplateTemporalFormat(
diff --git a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
index 82be105..00b79f1 100644
--- a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
+++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
@@ -71,6 +71,11 @@ final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat
     }
 
     @Override
+    public Object parse(String s) throws TemplateValueFormatException {
+        throw new ParsingNotSupportedException("To be implemented"); // TODO [FREEMARKER-35]
+    }
+
+    @Override
     public boolean isLocaleBound() {
         return false;
     }
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
index 47cd093..0e979f2 100644
--- a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
@@ -18,6 +18,8 @@
  */
 package freemarker.core;
 
+import static freemarker.template.utility.StringUtil.*;
+
 import java.time.DateTimeException;
 import java.time.Instant;
 import java.time.LocalDate;
@@ -31,9 +33,15 @@ import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.time.format.FormatStyle;
 import java.time.temporal.Temporal;
+import java.time.temporal.TemporalAccessor;
+import java.time.temporal.TemporalQuery;
+import java.util.IdentityHashMap;
 import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
 import java.util.TimeZone;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -42,7 +50,6 @@ import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
 import freemarker.template.utility.ClassUtil;
 import freemarker.template.utility.DateUtil;
-import freemarker.template.utility.StringUtil;
 
 /**
  * See {@link JavaTemplateTemporalFormatFactory}.
@@ -54,7 +61,11 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
     enum PreFormatValueConversion {
         INSTANT_TO_ZONED_DATE_TIME,
         SET_ZONE_FROM_OFFSET,
-        CONVERT_TO_CURRENT_ZONE
+        OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION
+    }
+
+    enum SpecialParsing {
+        OFFSET_TIME_DST_ERROR
     }
 
     static final String SHORT = "short";
@@ -71,19 +82,39 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
     private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile(
             "(?:" + ANY_FORMAT_STYLE + "(?:_" + ANY_FORMAT_STYLE + ")?)?");
 
+    private static final Map<Class<? extends Temporal>, TemporalQuery<? extends Temporal>> TEMPORAL_CLASS_TO_QUERY_MAP;
+    static {
+        TEMPORAL_CLASS_TO_QUERY_MAP = new IdentityHashMap<>();
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(Instant.class, Instant::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDate.class, LocalDate::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDateTime.class, LocalDateTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalTime.class, LocalTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetDateTime.class, JavaTemplateTemporalFormat::offsetDateTimeFrom);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetTime.class, OffsetTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(ZonedDateTime.class, ZonedDateTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(Year.class, Year::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(YearMonth.class, YearMonth::from);
+    }
+
     private final DateTimeFormatter dateTimeFormatter;
+    private final TemporalQuery<? extends Temporal> temporalQuery;
     private final ZoneId zoneId;
     private final String formatString;
+    private final Class<? extends Temporal> temporalClass;
     private final PreFormatValueConversion preFormatValueConversion;
+    private final SpecialParsing specialParsing;
 
     JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone)
             throws InvalidFormatParametersException {
-        temporalClass = _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+        this.temporalClass = _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+
+        temporalQuery = Objects.requireNonNull(TEMPORAL_CLASS_TO_QUERY_MAP.get(temporalClass));
 
         final Matcher formatStylePatternMatcher = FORMAT_STYLE_PATTERN.matcher(formatString);
         final boolean isFormatStyleString = formatStylePatternMatcher.matches();
         FormatStyle timePartFormatStyle; // Maybe changes later for re-attempts
         final FormatStyle datePartFormatStyle;
+        boolean formatWithZone;
 
         DateTimeFormatter dateTimeFormatter; // Maybe changes later for re-attempts
         if (isFormatStyleString) {
@@ -108,7 +139,7 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
                 dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(datePartFormatStyle);
             } else {
                 throw new InvalidFormatParametersException(
-                        "Format styles (like " + StringUtil.jQuote(formatString) + ") is not supported for "
+                        "Format styles (like " + jQuote(formatString) + ") is not supported for "
                         + temporalClass.getName() + " values.");
             }
         } else {
@@ -125,13 +156,15 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
         // Handling of time zone related edge cases
         if (isLocalTemporalClass(temporalClass)) {
             this.preFormatValueConversion = null;
+            this.specialParsing = null;
+            formatWithZone = false;
             if (isFormatStyleString && (temporalClass == LocalTime.class || temporalClass == LocalDateTime.class)) {
                 // The localized pattern possibly contains the time zone (for most locales, LONG and FULL does), so they
                 // fail with local temporals that have a time part. To work this issue around, we decrease the verbosity
                 // of the time style until formatting succeeds. (See also: JDK-8085887)
                 localFormatAttempt: while (true) {
                     try {
-                        dateTimeFormatter.format(LOCAL_DATE_TIME_SAMPLE); // We only care if it throws exception
+                        dateTimeFormatter.format(LOCAL_DATE_TIME_SAMPLE); // We only care if it throws exception or not
                         break localFormatAttempt; // It worked
                     } catch (DateTimeException e) {
                         timePartFormatStyle = getLessVerboseStyle(timePartFormatStyle);
@@ -153,45 +186,46 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
                     }
                 }
             }
-        } else {
+        } else { // is non-local temporal
             PreFormatValueConversion preFormatValueConversion;
-            nonLocalFormatAttempt: while (true) {
-                if (showsZone(dateTimeFormatter)) {
-                    if (temporalClass == Instant.class) {
-                        preFormatValueConversion = PreFormatValueConversion.INSTANT_TO_ZONED_DATE_TIME;
-                    } else if (isFormatStyleString &&
-                            (temporalClass == OffsetDateTime.class || temporalClass == OffsetTime.class)) {
-                        preFormatValueConversion = PreFormatValueConversion.SET_ZONE_FROM_OFFSET;
-                    } else {
-                        preFormatValueConversion = null;
-                    }
+            SpecialParsing specialParsing;
+            if (showsZone(dateTimeFormatter)) {
+                if (temporalClass == Instant.class) {
+                    preFormatValueConversion = PreFormatValueConversion.INSTANT_TO_ZONED_DATE_TIME;
+                    specialParsing = null;
+                } else if (isFormatStyleString &&
+                        (temporalClass == OffsetDateTime.class || temporalClass == OffsetTime.class)) {
+                    preFormatValueConversion = PreFormatValueConversion.SET_ZONE_FROM_OFFSET;
+                    specialParsing = null;
                 } else {
-                    if (temporalClass == OffsetTime.class && timeZone.useDaylightTime()) {
-                        if (isFormatStyleString) {
-                            // To find the closest style that already shows the offset
-                            timePartFormatStyle = getMoreVerboseStyle(timePartFormatStyle);
-                        }
-                        if (timePartFormatStyle == null) {
-                            throw new InvalidFormatParametersException(
-                                    "The format must show the time offset, as the current FreeMarker time zone, "
-                                            + StringUtil.jQuote(timeZone.getID()) +
-                                            ", may uses Daylight Saving Time, and thus "
-                                            + "it's not possible to convert the value to the local time in that zone, "
-                                            + "since we don't know the day.");
-                        }
-                        dateTimeFormatter = DateTimeFormatter.ofLocalizedTime(timePartFormatStyle);
-                        formatString = timePartFormatStyle.name().toLowerCase(Locale.ROOT);
-                        continue nonLocalFormatAttempt;
-                    } else {
-                        preFormatValueConversion = PreFormatValueConversion.CONVERT_TO_CURRENT_ZONE;
-                    }
+                    preFormatValueConversion = null;
+                    specialParsing = null;
                 }
-                break nonLocalFormatAttempt;
-            };
+                formatWithZone = false;
+            } else { // Doesn't show zone
+                if (temporalClass == OffsetTime.class) {
+                    // We give up, but delay the exception until the format is actually used, just in case
+                    // format creation is triggered without actually using it.
+                    preFormatValueConversion = PreFormatValueConversion.OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION;
+                    specialParsing = SpecialParsing.OFFSET_TIME_DST_ERROR;
+                    formatWithZone = false;
+                } else {
+                    // As no zone is shown, but our temporal class is not local, we tell the formatter convert to
+                    // the current time zone. Also, when parsing, that same time zone will be assumed.
+                    preFormatValueConversion = null;
+                    specialParsing = null;
+                    formatWithZone = true;
+                }
+            }
             this.preFormatValueConversion = preFormatValueConversion;
+            this.specialParsing = specialParsing;
         }
 
-        this.dateTimeFormatter = dateTimeFormatter.withLocale(locale);
+        dateTimeFormatter = dateTimeFormatter.withLocale(locale);
+        if (formatWithZone) {
+            dateTimeFormatter = dateTimeFormatter.withZone(timeZone.toZoneId());
+        }
+        this.dateTimeFormatter = dateTimeFormatter;
         this.formatString = formatString;
         this.zoneId = timeZone.toZoneId();
     }
@@ -201,36 +235,32 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
         DateTimeFormatter dateTimeFormatter = this.dateTimeFormatter;
         Temporal temporal = TemplateFormatUtil.getNonNullTemporal(tm);
 
-        if (preFormatValueConversion == PreFormatValueConversion.INSTANT_TO_ZONED_DATE_TIME) {
-            temporal = ((Instant) temporal).atZone(zoneId);
-        } else if (preFormatValueConversion == PreFormatValueConversion.CONVERT_TO_CURRENT_ZONE) {
-            if (temporal instanceof Instant) {
-                temporal = ((Instant) temporal).atZone(zoneId);
-            } else if (temporal instanceof OffsetDateTime) {
-                temporal = ((OffsetDateTime) temporal).atZoneSameInstant(zoneId);
-            } else if (temporal instanceof ZonedDateTime) {
-                temporal = ((ZonedDateTime) temporal).withZoneSameInstant(zoneId);
-            } else if (temporal instanceof OffsetTime) {
-                // Because of logic in the constructor, this is only reached if the zone never uses Daylight Saving.
-                temporal = ((OffsetTime) temporal).withOffsetSameInstant(zoneId.getRules().getOffset(Instant.EPOCH));
-            } else {
-                throw new InvalidFormatParametersException(
-                        "Don't know how to convert value of type " + temporal.getClass().getName() + " to the current "
-                                + "FreeMarker time zone, " + StringUtil.jQuote(zoneId.getId()) + ", which is "
-                                + "needed to format with " + StringUtil.jQuote(formatString) + ".");
-            }
-        } else if (preFormatValueConversion == PreFormatValueConversion.SET_ZONE_FROM_OFFSET) {
-            // Formats like "long" want a time zone field, but oddly, they don't treat the zoneOffset as such.
-            if (temporal instanceof OffsetDateTime) {
-                OffsetDateTime offsetDateTime = (OffsetDateTime) temporal;
-                temporal = ZonedDateTime.of(offsetDateTime.toLocalDateTime(), offsetDateTime.getOffset());
-            } else if (temporal instanceof OffsetTime) {
-                // There's no ZonedTime class, so we must manipulate the format.
-                dateTimeFormatter = dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset());
-            } else {
-                throw new IllegalArgumentException(
-                        "Formatter was created for OffsetTime or OffsetDateTime, but value was a "
-                                + ClassUtil.getShortClassNameOfObject(temporal));
+        if (preFormatValueConversion != null) {
+            switch (preFormatValueConversion) {
+                case INSTANT_TO_ZONED_DATE_TIME:
+                    // Typical date-time formats will fail with "UnsupportedTemporalTypeException: Unsupported field:
+                    // YearOfEra" if we leave the value as Instant. (But parse(String, Instant::from) has no similar
+                    // issue.)
+                    temporal = ((Instant) temporal).atZone(zoneId);
+                    break;
+                case SET_ZONE_FROM_OFFSET:
+                    // Formats like "long" want a time zone field, but oddly, they don't treat the zoneOffset as such.
+                    if (temporal instanceof OffsetDateTime) {
+                        OffsetDateTime offsetDateTime = (OffsetDateTime) temporal;
+                        temporal = ZonedDateTime.of(offsetDateTime.toLocalDateTime(), offsetDateTime.getOffset());
+                    } else if (temporal instanceof OffsetTime) {
+                        // There's no ZonedTime class, so we must manipulate the format.
+                        dateTimeFormatter = dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset());
+                    } else {
+                        throw new IllegalArgumentException(
+                                "Formatter was created for OffsetTime or OffsetDateTime, but value was a "
+                                        + ClassUtil.getShortClassNameOfObject(temporal));
+                    }
+                    break;
+                case OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION:
+                    throw newOffsetTimeWithoutOffsetOnTheFormatException();
+                default:
+                    throw new BugException();
             }
         }
 
@@ -241,6 +271,38 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
         }
     }
 
+    private static InvalidFormatParametersException newOffsetTimeWithoutOffsetOnTheFormatException() {
+        return new InvalidFormatParametersException(
+                "The time format must show the time offset when dealing with offset-time type, because in case the "
+                        + "current FreeMarker time zone uses Daylight Saving Time, it's impossible to convert between "
+                        + "offset-time, and the local-time, since we don't know the day.");
+    }
+
+    @Override
+    public Object parse(String s) throws TemplateValueFormatException {
+        if (specialParsing != null) {
+            switch (specialParsing) {
+                case OFFSET_TIME_DST_ERROR:
+                    throw newOffsetTimeWithoutOffsetOnTheFormatException();
+                default:
+                    throw new BugException();
+            }
+        }
+
+        try {
+            return dateTimeFormatter.parse(s, temporalQuery);
+        } catch (DateTimeParseException e) {
+            throw new UnparsableValueException(
+                    "Failed to parse value " + jQuote(s) + " with format " + jQuote(formatString)
+                            + ", and target class " + temporalClass.getSimpleName() + ", "
+                            + "locale " + jQuote(dateTimeFormatter.getLocale()) + ", "
+                            + "zoneId " + jQuote(zoneId) + ".\n"
+                            + "(Used this DateTimeFormatter: " + dateTimeFormatter + ")\n"
+                            + "(Root cause message: " + e.getMessage() + ")",
+                    e);
+        }
+    }
+
     @Override
     public String getDescription() {
         return formatString;
@@ -280,6 +342,10 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
                 || normalizedTemporalClass == YearMonth.class;
     }
 
+    private static OffsetDateTime offsetDateTimeFrom(TemporalAccessor temporal) {
+        return ZonedDateTime.from(temporal).toOffsetDateTime();
+    }
+
     private static FormatStyle getMoreVerboseStyle(FormatStyle style) {
         switch (style) {
             case SHORT:
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java b/src/main/java/freemarker/core/TemplateTemporalFormat.java
index e864487..aeaffd9 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/TemplateTemporalFormat.java
@@ -67,6 +67,18 @@ public abstract class TemplateTemporalFormat extends TemplateValueFormat {
      */
     public abstract boolean isTimeZoneBound();
 
-    // TODO [FREEMARKER-35] Add parse method
+    /**
+     * Parsers a string to a {@link Temporal}, according to this format. Some format implementations may throw
+     * {@link ParsingNotSupportedException} here.
+     *
+     * @param s
+     *            The string to parse
+     *
+     * @return The interpretation of the text either as a {@link Temporal} or {@link TemplateTemporalModel}. Typically,
+     *         a {@link Temporal}. {@link TemplateTemporalModel} is used if you have to attach some application-specific
+     *         meta-information that's also extracted during {@link #formatToPlainText(TemplateTemporalModel)} (so if
+     *         you format something and then parse it, you get back an equivalent result). It can't be {@code null}.
+     */
+    public abstract Object parse(String s) throws TemplateValueFormatException;
 
 }
diff --git a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
index ff5ba63..b3d6c5a 100644
--- a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
@@ -78,6 +78,11 @@ public class EpochMillisDivTemplateTemporalFormatFactory extends TemplateTempora
         }
 
         @Override
+        public Object parse(String s) throws TemplateValueFormatException {
+            throw new ParsingNotSupportedException("Parsing is not implement for this test class");
+        }
+
+        @Override
         public boolean isLocaleBound() {
             return false;
         }
diff --git a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
index a51b6ad..a88c905 100644
--- a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
@@ -62,6 +62,11 @@ public class EpochMillisTemplateTemporalFormatFactory extends TemplateTemporalFo
             }
             return String.valueOf(epochMillis);
         }
+        
+        @Override
+        public Object parse(String s) throws TemplateValueFormatException {
+            throw new ParsingNotSupportedException("Parsing is not implement for this test class");
+        }
 
         @Override
         public boolean isLocaleBound() {
diff --git a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
index 5226fd2..cb9a954 100644
--- a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
@@ -60,6 +60,11 @@ public class HTMLISOTemplateTemporalFormatFactory extends TemplateTemporalFormat
         }
 
         @Override
+        public Object parse(String s) throws TemplateValueFormatException {
+            throw new ParsingNotSupportedException("Parsing is not implement for this test class");
+        }
+
+        @Override
         public boolean isLocaleBound() {
             return false;
         }
diff --git a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
index 446f3c3..f8827e2 100644
--- a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
@@ -69,6 +69,11 @@ public class LocAndTZSensitiveTemplateTemporalFormatFactory extends TemplateTemp
         }
 
         @Override
+        public Object parse(String s) throws TemplateValueFormatException {
+            throw new ParsingNotSupportedException("Parsing is not implement for this test class");
+        }
+
+        @Override
         public boolean isLocaleBound() {
             return true;
         }
diff --git a/src/test/java/freemarker/core/TemporalFormatTest.java b/src/test/java/freemarker/core/TemporalFormatTest.java
index 83268e0..ab6e479 100644
--- a/src/test/java/freemarker/core/TemporalFormatTest.java
+++ b/src/test/java/freemarker/core/TemporalFormatTest.java
@@ -19,6 +19,7 @@
 
 package freemarker.core;
 
+import static freemarker.template.utility.StringUtil.*;
 import static freemarker.test.hamcerst.Matchers.*;
 import static org.hamcrest.CoreMatchers.*;
 import static org.junit.Assert.*;
@@ -32,9 +33,11 @@ import java.time.OffsetDateTime;
 import java.time.OffsetTime;
 import java.time.Year;
 import java.time.YearMonth;
+import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
 import java.time.temporal.Temporal;
+import java.util.Locale;
 import java.util.TimeZone;
 import java.util.function.Consumer;
 
@@ -44,7 +47,10 @@ import freemarker.template.Configuration;
 import freemarker.template.SimpleTemporal;
 import freemarker.template.Template;
 import freemarker.template.TemplateException;
+import freemarker.template.TemplateTemporalModel;
+import freemarker.template.utility.ClassUtil;
 import freemarker.template.utility.DateUtil;
+import freemarker.test.hamcerst.Matchers;
 
 public class TemporalFormatTest {
 
@@ -52,20 +58,7 @@ public class TemporalFormatTest {
     public void testOffsetTimeAndZones() throws TemplateException, IOException {
         OffsetTime offsetTime = OffsetTime.of(LocalTime.of(10, 0, 0), ZoneOffset.ofHours(1));
 
-        TimeZone zoneWithoutDST = TimeZone.getTimeZone("GMT+2");
-        assertFalse(zoneWithoutDST.useDaylightTime());
-
-        TimeZone zoneWithDST = TimeZone.getTimeZone("America/New_York");
-        assertTrue(zoneWithDST.useDaylightTime());
-
-        assertEquals(
-                "11:00",
-                formatTemporal(
-                        conf -> {
-                            conf.setTimeFormat("HH:mm");
-                            conf.setTimeZone(zoneWithoutDST);
-                        },
-                        offsetTime));
+        TimeZone timeZone = TimeZone.getTimeZone("America/New_York");
 
         try {
             assertEquals(
@@ -73,7 +66,7 @@ public class TemporalFormatTest {
                     formatTemporal(
                             conf -> {
                                 conf.setTimeFormat("HH:mm");
-                                conf.setTimeZone(zoneWithDST);
+                                conf.setTimeZone(timeZone);
                             },
                             offsetTime));
             fail();
@@ -86,18 +79,10 @@ public class TemporalFormatTest {
                 formatTemporal(
                         conf -> {
                             conf.setTimeFormat("HH:mmX");
-                            conf.setTimeZone(zoneWithDST);
+                            conf.setTimeZone(timeZone);
                         },
                         offsetTime));
 
-        assertEquals(
-                "10:00+01",
-                formatTemporal(
-                        conf -> {
-                            conf.setTimeFormat("HH:mmX");
-                            conf.setTimeZone(zoneWithoutDST);
-                        },
-                        offsetTime));
     }
 
     @Test
@@ -212,6 +197,82 @@ public class TemporalFormatTest {
         }
     }
 
+    @Test
+    public void testDateTimeParsing() throws TemplateException, TemplateValueFormatException {
+        ZoneId zoneId = ZoneId.of("America/New_York");
+        TimeZone timeZone = TimeZone.getTimeZone(zoneId);
+
+        for (int i = 0; i < 2; i++) {
+            String stringToParse = i == 0 ? "2020-12-10 13:14" : "2020-07-10 13:14";
+            LocalDateTime localDateTime = i == 0
+                    ? LocalDateTime.of(2020, 12, 10, 13, 14)
+                    : LocalDateTime.of(2020, 07, 10, 13, 14);
+
+            ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, zoneId);
+            assertParsingResults(
+                    conf -> {
+                        conf.setDateTimeFormat("y-MM-dd HH:mm");
+                        conf.setTimeZone(timeZone);
+                    },
+                    stringToParse, localDateTime,
+                    stringToParse, zonedDateTime.toOffsetDateTime(),
+                    stringToParse, zonedDateTime,
+                    stringToParse, zonedDateTime.toInstant());
+
+            // TODO if zone is shown
+        }
+    }
+
+    @Test
+    public void testDateParsing() throws TemplateException, TemplateValueFormatException {
+        String stringToParse = "2020-11-10";
+        LocalDate localDate = LocalDate.of(2020, 11, 10);
+        assertParsingResults(
+                conf -> conf.setDateFormat("y-MM-dd"),
+                stringToParse, localDate);
+    }
+
+    @Test
+    public void testLocalTimeParsing() throws TemplateException, TemplateValueFormatException {
+        String stringToParse = "13:14";
+        assertParsingResults(
+                conf -> conf.setTimeFormat("HH:mm"),
+                stringToParse, LocalTime.of(13, 14));
+        // TODO if zone is shown
+    }
+
+    @Test
+    public void testParsingLocalization() throws TemplateException, TemplateValueFormatException {
+        // TODO
+    }
+
+    @Test
+    public void testOffsetTimeParsing() throws TemplateException, TemplateValueFormatException {
+        ZoneId zoneId = ZoneId.of("America/New_York");
+        TimeZone timeZone = TimeZone.getTimeZone(zoneId);
+
+        assertParsingResults(
+                conf -> {
+                    conf.setTimeFormat("HH:mmXX");
+                    conf.setTimeZone(timeZone);
+                },
+                "13:14+0130",
+                OffsetTime.of(LocalTime.of(13, 14), ZoneOffset.ofHoursMinutesSeconds(1, 30, 0)));
+
+        try {
+            assertParsingResults(
+                    conf -> {
+                        conf.setTimeFormat("HH:mm");
+                        conf.setTimeZone(timeZone);
+                    },
+                    "13:14",
+                    OffsetTime.now() /* Value doesn't matter, as parsing will fail */);
+            fail("OffsetTime parsing should have failed when offset is not specified");
+        } catch (InvalidFormatParametersException e) {
+            assertThat(e.getMessage(), Matchers.containsStringIgnoringCase("daylight saving"));
+        }
+    }
+
     static private String formatTemporal(Consumer<Configurable> configurer, Temporal... values) throws
             TemplateException {
         Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
@@ -235,4 +296,69 @@ public class TemporalFormatTest {
 
         return sb.toString();
     }
+
+    static private void assertParsingResults(
+            Consumer<Configurable> configurer,
+            Object... stringsAndExpectedResults) throws TemplateException, TemplateValueFormatException {
+        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
+        conf.setTimeZone(DateUtil.UTC);
+        conf.setLocale(Locale.US);
+
+        configurer.accept(conf);
+
+        Environment env = null;
+        try {
+            env = new Template(null, "", conf).createProcessingEnvironment(null, null);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+
+        if (stringsAndExpectedResults.length % 2 != 0) {
+            throw new IllegalArgumentException(
+                    "stringsAndExpectedResults.length must be even, but was " + stringsAndExpectedResults.length + ".");
+        }
+        for (int i = 0; i < stringsAndExpectedResults.length; i += 2) {
+            Object value = stringsAndExpectedResults[i];
+            if (!(value instanceof String)) {
+                throw new IllegalArgumentException("stringsAndExpectedResults[" + i + "] should be a String");
+            }
+            String string = (String) value;
+
+            value = stringsAndExpectedResults[i + 1];
+            if (!(value instanceof Temporal)) {
+                throw new IllegalArgumentException("stringsAndExpectedResults[" + (i + 1) + "] should be a Temporal");
+            }
+            Temporal expectedResult = (Temporal) value;
+
+            Class<? extends Temporal> temporalClass = expectedResult.getClass();
+            TemplateTemporalFormat templateTemporalFormat = env.getTemplateTemporalFormat(temporalClass);
+
+            Temporal actualResult;
+            {
+                Object actualResultObject = templateTemporalFormat.parse(string);
+                if (actualResultObject instanceof Temporal) {
+                    actualResult = (Temporal) actualResultObject;
+                } else if (actualResultObject instanceof TemplateTemporalModel) {
+                    actualResult = ((TemplateTemporalModel) actualResultObject).getAsTemporal();
+                } else {
+                    throw new AssertionError(
+                            "Parsing result of " + jQuote(string) + " is not of an expected type: "
+                                    + ClassUtil.getShortClassNameOfObject(actualResultObject));
+                }
+            }
+
+            if (!expectedResult.equals(actualResult)) {
+                throw new AssertionError(
+                        "Parsing result of " + jQuote(string) + " "
+                                + "(with temporalFormat[" + temporalClass.getSimpleName() + "]="
+                                + jQuote(env.getTemporalFormat(temporalClass)) + ", "
+                                + "timeZone=" + jQuote(env.getTimeZone().toZoneId()) + ", "
+                                + "locale=" + jQuote(env.getLocale()) + ") "
+                                + "differs from expected.\n"
+                                + "Expected: " + expectedResult + "\n"
+                                + "Actual:   " + actualResult);
+            }
+        }
+    }
+
 }
diff --git a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
index 05991b9..65c146e 100644
--- a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
+++ b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
@@ -22,15 +22,14 @@
 <@assertEquals expected="Apr 5, 2003" actual=localDate?string />
 <@assertEquals expected="6:07:08 AM" actual=localTime?string />
 <@assertEquals expected="Apr 5, 2003 7:07:08 AM" actual=offsetDateTime?string />
-<@assertEquals expected="7:07:08 AM" actual=offsetTime?string />
+<@assertFails message="Daylight Saving">${offsetTime}</@>
 <@assertEquals expected="2003" actual=year?string />
 <@assertEquals expected="2003-04" actual=yearMonth?string />
 <@assertEquals expected="Apr 5, 2003 7:07:08 AM" actual=zonedDateTime?string />
 
 <#setting timeZone="America/New_York">
 <@assertEquals expected="6:07:08 AM" actual=localTime?string />
-<#-- Automatic medium->long step up: -->
-<@assertEquals expected="6:07:08 AM Z" actual=offsetTime?string />
+<@assertFails message="Daylight Saving">${offsetTime}</@>
 
 <@assertEquals expected="2003-04-05T01:07:08-05:00" actual=instant?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08" actual=localDateTime?string.iso />
@@ -81,10 +80,8 @@
 <@assertEquals expected="5 avril 2003 06:07:08" actual=localDateTime?string.long />
 <@assertEquals expected="samedi 5 avril 2003 06:07:08" actual=localDateTime?string.full />
 
-<#-- Automatic short->medium->long step up: -->
-<@assertEquals expected="06:07:08 Z" actual=offsetTime?string.short />
-<#-- Automatic medium->long step up: -->
-<@assertEquals expected="06:07:08 Z" actual=offsetTime?string.medium />
+<@assertFails message="Daylight Saving">${offsetTime?string.short}</@>
+<@assertFails message="Daylight Saving">${offsetTime?string.medium}</@>
 <@assertEquals expected="06:07:08 Z" actual=offsetTime?string.long />
 <@assertEquals expected="06 h 07 Z" actual=offsetTime?string.full />
 

[freemarker] 01/03: [FREEMARKER-35] Deleted now unused classes.

Posted by dd...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ddekany pushed a commit to branch FREEMARKER-35
in repository https://gitbox.apache.org/repos/asf/freemarker.git

commit cbdb2e4a322879cff939c5c80fd820f1294cc4ad
Author: ddekany <dd...@apache.org>
AuthorDate: Wed Dec 29 18:25:31 2021 +0100

    [FREEMARKER-35] Deleted now unused classes.
---
 .../core/ToStringTemplateTemporalFormat.java       | 70 ----------------------
 .../ToStringTemplateTemporalFormatFactory.java     | 49 ---------------
 2 files changed, 119 deletions(-)

diff --git a/src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java b/src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java
deleted file mode 100644
index 0ee291c..0000000
--- a/src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package freemarker.core;
-
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.temporal.Temporal;
-import java.util.TimeZone;
-
-import freemarker.template.TemplateModelException;
-import freemarker.template.TemplateTemporalModel;
-
-/**
- * See {@link ToStringTemplateTemporalFormatFactory}.
- *
- * @Deprected TODO [FREEMARKER-35] I guess we shouldn't need this.
- *
- * @since 2.3.32
- */
-class ToStringTemplateTemporalFormat extends TemplateTemporalFormat {
-
-    private final ZoneId timeZone;
-
-    ToStringTemplateTemporalFormat(TimeZone timeZone) {
-        this.timeZone = timeZone.toZoneId();
-    }
-
-    @Override
-    public String formatToPlainText(TemplateTemporalModel temporalModel) throws TemplateValueFormatException,
-            TemplateModelException {
-        Temporal temporal = TemplateFormatUtil.getNonNullTemporal(temporalModel);
-        // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did
-        if (temporal instanceof Instant) {
-            temporal = ((Instant) temporal).atZone(timeZone);
-        }
-        return temporal.toString();
-    }
-
-    @Override
-    public boolean isLocaleBound() {
-        return false;
-    }
-
-    @Override
-    public boolean isTimeZoneBound() {
-        return true;
-    }
-
-    @Override
-    public String getDescription() {
-        return "toString()";
-    }
-}
diff --git a/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java
deleted file mode 100644
index a2e53df..0000000
--- a/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package freemarker.core;
-
-import java.time.temporal.Temporal;
-import java.util.Locale;
-import java.util.TimeZone;
-
-/**
- * Gives a {@link TemplateTemporalFormat} that simply calls {@link Object#toString()}
- *
- * @Deprected TODO [FREEMARKER-35] I guess we shouldn't need this.
- *
- * @since 2.3.32
- */
-class ToStringTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
-
-    static final ToStringTemplateTemporalFormatFactory INSTANCE = new ToStringTemplateTemporalFormatFactory();
-
-    private ToStringTemplateTemporalFormatFactory() {
-        // Not meant to be called from outside
-    }
-
-    @Override
-    public TemplateTemporalFormat get(String params, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, Environment env) throws
-            TemplateValueFormatException {
-        if (!params.isEmpty()) {
-            throw new InvalidFormatParametersException("toString format doesn't support parameters");
-        }
-        return new ToStringTemplateTemporalFormat(timeZone);
-    }
-}

[freemarker] 03/03: [FREEMARKER-35] Continued temporal parsing, improved ISO (and XS) formatters. Some code cleanup.

Posted by dd...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ddekany pushed a commit to branch FREEMARKER-35
in repository https://gitbox.apache.org/repos/asf/freemarker.git

commit 691dfaba50bb0db363f86b6614c88df626f06160
Author: ddekany <dd...@apache.org>
AuthorDate: Sun Jan 2 23:46:41 2022 +0100

    [FREEMARKER-35] Continued temporal parsing, improved ISO (and XS) formatters. Some code cleanup.
---
 src/main/java/freemarker/core/Configurable.java    |   3 +-
 src/main/java/freemarker/core/Environment.java     |   8 +-
 .../ISOLikeTemplateTemporalTemporalFormat.java     |  74 ++-
 .../core/ISOTemplateTemporalFormatFactory.java     | 172 +++++--
 .../core/JavaTemplateTemporalFormat.java           |  30 +-
 .../core/XSTemplateTemporalFormatFactory.java      |  35 +-
 .../java/freemarker/core/_CoreTemporalUtils.java   | 124 -----
 src/main/java/freemarker/template/Template.java    |   2 +-
 .../java/freemarker/template/utility/DateUtil.java | 328 +-------------
 .../freemarker/template/utility/StringUtil.java    |  10 +-
 .../freemarker/template/utility/TemporalUtils.java | 499 +++++++++++++++++++++
 .../core/AbstractTemporalFormatTest.java           | 131 ++++++
 ...ava => TemporalFormatWithCustomFormatTest.java} |   4 +-
 .../core/TemporalFormatWithIsoFormatTest.java      | 313 +++++++++++++
 ....java => TemporalFormatWithJavaFormatTest.java} | 231 +++++-----
 .../utility/DateUtilsPatternParsingTest.java       |  57 ++-
 .../utility/TemporalUtilsTest.java}                |  25 +-
 17 files changed, 1349 insertions(+), 697 deletions(-)

diff --git a/src/main/java/freemarker/core/Configurable.java b/src/main/java/freemarker/core/Configurable.java
index 94c06c3..17fca68 100644
--- a/src/main/java/freemarker/core/Configurable.java
+++ b/src/main/java/freemarker/core/Configurable.java
@@ -82,6 +82,7 @@ import freemarker.template.Version;
 import freemarker.template._TemplateAPI;
 import freemarker.template.utility.NullArgumentException;
 import freemarker.template.utility.StringUtil;
+import freemarker.template.utility.TemporalUtils;
 
 /**
  * This is a common superclass of {@link freemarker.template.Configuration},
@@ -1457,7 +1458,7 @@ public class Configurable {
         } else {
             // Handle the unlikely situation that in some future Java version we can have subclasses.
             Class<? extends Temporal> normTemporalClass =
-                    _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+                    TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
             if (normTemporalClass == temporalClass) {
                 throw new IllegalArgumentException("There's no temporal format setting for this class: "
                         + temporalClass.getName());
diff --git a/src/main/java/freemarker/core/Environment.java b/src/main/java/freemarker/core/Environment.java
index 24a0137..374bd98 100644
--- a/src/main/java/freemarker/core/Environment.java
+++ b/src/main/java/freemarker/core/Environment.java
@@ -82,6 +82,7 @@ import freemarker.template.utility.DateUtil.DateToISO8601CalendarFactory;
 import freemarker.template.utility.NullWriter;
 import freemarker.template.utility.StringUtil;
 import freemarker.template.utility.TemplateModelUtils;
+import freemarker.template.utility.TemporalUtils;
 import freemarker.template.utility.UndeclaredThrowableException;
 
 /**
@@ -2342,7 +2343,12 @@ public final class Environment extends Configurable {
             String settingName;
             String settingValue;
             try {
-                settingName = _CoreTemporalUtils.temporalClassToFormatSettingName(temporalClass);
+                settingName = TemporalUtils.temporalClassToFormatSettingName(
+                        temporalClass,
+                        blamedTemporalSourceExp != null
+                                ? blamedTemporalSourceExp.getTemplate().getActualNamingConvention()
+                                        == Configuration.CAMEL_CASE_NAMING_CONVENTION
+                                : false);
                 settingValue = getTemporalFormat(temporalClass);
             } catch (IllegalArgumentException e2) {
                 settingName = "???";
diff --git a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
index 00b79f1..482b257 100644
--- a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
+++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
@@ -19,15 +19,25 @@
 
 package freemarker.core;
 
+import static freemarker.template.utility.StringUtil.*;
+
 import java.time.DateTimeException;
 import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.OffsetTime;
+import java.time.Year;
+import java.time.YearMonth;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.time.temporal.Temporal;
+import java.time.temporal.TemporalQuery;
 import java.util.TimeZone;
 
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
+import freemarker.template.utility.TemporalUtils;
 
 // TODO [FREEMARKER-35] These should support parameters similar to {@link ISOTemplateDateFormat},
 
@@ -41,17 +51,24 @@ final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat
     private final boolean instantConversion;
     private final ZoneId zoneId;
     private final String description;
+    private final TemporalQuery temporalQuery;
+    private final Class<? extends Temporal> temporalClass;
+    private final DateTimeFormatter parserExtendedDateTimeFormatter;
+    private final DateTimeFormatter parserBasicDateTimeFormatter;
 
-    public ISOLikeTemplateTemporalTemporalFormat(
-            DateTimeFormatter dateTimeFormatter, Class<? extends Temporal> temporalClass, TimeZone zone, String description) {
+    ISOLikeTemplateTemporalTemporalFormat(
+            DateTimeFormatter dateTimeFormatter,
+            DateTimeFormatter parserExtendedDateTimeFormatter,
+            DateTimeFormatter parserBasicDateTimeFormatter,
+            Class<? extends Temporal> temporalClass, TimeZone zone, String formatString) {
         this.dateTimeFormatter = dateTimeFormatter;
+        this.parserExtendedDateTimeFormatter = parserExtendedDateTimeFormatter;
+        this.parserBasicDateTimeFormatter = parserBasicDateTimeFormatter;
+        this.temporalQuery = TemporalUtils.getTemporalQuery(temporalClass);
         this.instantConversion = Instant.class.isAssignableFrom(temporalClass);
-        if (instantConversion) {
-            zoneId = zone.toZoneId();
-        } else {
-            zoneId = null;
-        }
-        this.description = description;
+        this.temporalClass = temporalClass;
+        this.zoneId = zone.toZoneId();
+        this.description = formatString;
     }
 
     @Override
@@ -72,7 +89,46 @@ final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat
 
     @Override
     public Object parse(String s) throws TemplateValueFormatException {
-        throw new ParsingNotSupportedException("To be implemented"); // TODO [FREEMARKER-35]
+        DateTimeFormatter parserDateTimeFormatter = parserBasicDateTimeFormatter == null || isExtendedFormatString(s)
+                ? parserExtendedDateTimeFormatter : parserBasicDateTimeFormatter;
+        try {
+            return parserDateTimeFormatter.parse(s, temporalQuery);
+        } catch (DateTimeParseException e) {
+            throw new UnparsableValueException(
+                    "Failed to parse value " + jQuote(s) + " with format " + jQuote(description)
+                            + ", and target class " + temporalClass.getSimpleName() + ", "
+                            + "zoneId " + jQuote(zoneId) + ".\n"
+                            + "(Used this DateTimeFormatter: " + parserDateTimeFormatter + ")\n"
+                            + "(Root cause message: " + e.getMessage() + ")",
+                    e);
+        }
+    }
+
+    private boolean isExtendedFormatString(String s) throws UnparsableValueException {
+        if (temporalClass == LocalDate.class || temporalClass == YearMonth.class) {
+            return !s.isEmpty() && s.indexOf('-', 1) != -1;
+        } else if (temporalClass == LocalTime.class || temporalClass == OffsetTime.class) {
+            return s.indexOf(":") != -1;
+        } else if (temporalClass == Year.class) {
+            return false;
+        } else {
+            int tIndex = s.indexOf('T');
+            if (tIndex < 1) {
+                throw new UnparsableValueException(
+                        "Failed to parse value " + jQuote(s) + " with format " + jQuote(description)
+                                + ", and target class " + temporalClass.getSimpleName() + ": "
+                                + "Character \"T\" must be used to separate the date and time part.");
+            }
+            if (s.indexOf(":", tIndex + 1) != -1) {
+                return true;
+            }
+            // Note: false for: -5000101T00, as there the last '-' has index 0
+            return s.lastIndexOf('-', tIndex - 1) > 0;
+        }
+    }
+
+    private boolean temporalClassHasNoTimePart() {
+        return temporalClass == LocalDate.class || temporalClass == Year.class || temporalClass == YearMonth.class;
     }
 
     @Override
diff --git a/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
index 50edb2c..48b48a5 100644
--- a/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
+++ b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
@@ -19,18 +19,29 @@
 
 package freemarker.core;
 
+import static java.time.temporal.ChronoField.*;
+
+import java.time.Instant;
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.time.LocalTime;
+import java.time.OffsetDateTime;
 import java.time.OffsetTime;
 import java.time.Year;
 import java.time.YearMonth;
+import java.time.ZonedDateTime;
+import java.time.chrono.IsoChronology;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.ResolverStyle;
+import java.time.format.SignStyle;
 import java.time.temporal.ChronoField;
 import java.time.temporal.Temporal;
 import java.util.Locale;
 import java.util.TimeZone;
 
+import freemarker.template.utility.TemporalUtils;
+
 /**
  * Format factory related to {@link someJava8Temporal?string.iso}, {@link someJava8Temporal?string.iso_...}, etc.
  */
@@ -44,8 +55,9 @@ class ISOTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
 
     static final DateTimeFormatter ISO8601_DATE_FORMAT = new DateTimeFormatterBuilder()
             .append(DateTimeFormatter.ISO_LOCAL_DATE)
-            .toFormatter()
-            .withLocale(Locale.US);
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
     static final DateTimeFormatter ISO8601_DATE_TIME_FORMAT = new DateTimeFormatterBuilder()
             .append(DateTimeFormatter.ISO_LOCAL_DATE)
@@ -55,12 +67,13 @@ class ISOTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
             .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
             .appendLiteral(":")
             .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
             .optionalStart()
-            .appendOffsetId()
+            .appendOffset("+HH:MM", "Z")
             .optionalEnd()
-            .toFormatter()
-            .withLocale(Locale.US);
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
     static final DateTimeFormatter ISO8601_TIME_FORMAT = new DateTimeFormatterBuilder()
             .appendValue(ChronoField.HOUR_OF_DAY, 2)
@@ -68,24 +81,120 @@ class ISOTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
             .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
             .appendLiteral(":")
             .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
             .optionalStart()
-            .appendOffsetId()
+            .appendOffset("+HH:MM", "Z")
             .optionalEnd()
-            .toFormatter()
-            .withLocale(Locale.US);
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
-    static final DateTimeFormatter ISO8601_YEARMONTH_FORMAT = new DateTimeFormatterBuilder()
+    static final DateTimeFormatter ISO8601_YEAR_MONTH_FORMAT = new DateTimeFormatterBuilder()
             .appendValue(ChronoField.YEAR)
             .appendLiteral("-")
             .appendValue(ChronoField.MONTH_OF_YEAR, 2)
-            .toFormatter()
-            .withLocale(Locale.US);
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
     static final DateTimeFormatter ISO8601_YEAR_FORMAT = new DateTimeFormatterBuilder()
             .appendValue(ChronoField.YEAR)
             .toFormatter()
-            .withLocale(Locale.US);
+            .withLocale(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_EXTENDED_DATE_TIME_FORMAT = new DateTimeFormatterBuilder()
+            .append(DateTimeFormatter.ISO_LOCAL_DATE)
+            .appendLiteral('T')
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .optionalStart()
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .optionalStart()
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+            .optionalEnd()
+            .optionalEnd()
+            .optionalStart()
+            .appendOffset("+HH:mm", "Z")
+            .optionalEnd()
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_BASIC_DATE_TIME_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
+            .appendValue(MONTH_OF_YEAR, 2)
+            .appendValue(DAY_OF_MONTH, 2)
+            .appendLiteral('T')
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .optionalStart()
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .optionalStart()
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+            .optionalEnd()
+            .optionalEnd()
+            .optionalStart()
+            .appendOffset("+HHmm", "Z")
+            .optionalEnd()
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_EXTENDED_DATE_FORMAT = ISO8601_DATE_FORMAT;
+
+    static final DateTimeFormatter PARSER_ISO8601_BASIC_DATE_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
+            .appendValue(MONTH_OF_YEAR, 2)
+            .appendValue(DAY_OF_MONTH, 2)
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_EXTENDED_TIME_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .optionalStart()
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .optionalStart()
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+            .optionalEnd()
+            .optionalEnd()
+            .optionalStart()
+            .appendOffset("+HH:mm", "Z")
+            .optionalEnd()
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_BASIC_TIME_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .optionalStart()
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .optionalStart()
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+            .optionalEnd()
+            .optionalEnd()
+            .optionalStart()
+            .appendOffset("+HHmm", "Z")
+            .optionalEnd()
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_EXTENDED_YEAR_MONTH_FORMAT = ISO8601_YEAR_MONTH_FORMAT;
+    static final DateTimeFormatter PARSER_ISO8601_BASIC_YEAR_MONTH_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(ChronoField.YEAR)
+            .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
     @Override
     public TemplateTemporalFormat get(String params, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, Environment env) throws
@@ -100,31 +209,44 @@ class ISOTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
 
     private static ISOLikeTemplateTemporalTemporalFormat getISOFormatter(Class<? extends Temporal> temporalClass, TimeZone timeZone) {
         final DateTimeFormatter dateTimeFormatter;
+        final DateTimeFormatter parserExtendedDateTimeFormatter;
+        final DateTimeFormatter parserBasicDateTimeFormatter;
         final String description;
+        temporalClass = TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
         if (temporalClass == LocalTime.class || temporalClass == OffsetTime.class) {
             dateTimeFormatter = ISO8601_TIME_FORMAT;
+            parserExtendedDateTimeFormatter = PARSER_ISO8601_EXTENDED_TIME_FORMAT;
+            parserBasicDateTimeFormatter = PARSER_ISO8601_BASIC_TIME_FORMAT;
             description = "ISO 8601 (subset) time";
         } else if (temporalClass == Year.class) {
             dateTimeFormatter = ISO8601_YEAR_FORMAT;
+            parserExtendedDateTimeFormatter = ISO8601_YEAR_FORMAT;
+            parserBasicDateTimeFormatter = null;
             description = "ISO 8601 (subset) year";
         } else if (temporalClass == YearMonth.class) {
-            dateTimeFormatter = ISO8601_YEARMONTH_FORMAT;
+            dateTimeFormatter = ISO8601_YEAR_MONTH_FORMAT;
+            parserExtendedDateTimeFormatter = PARSER_ISO8601_EXTENDED_YEAR_MONTH_FORMAT;
+            parserBasicDateTimeFormatter = PARSER_ISO8601_BASIC_YEAR_MONTH_FORMAT;
             description = "ISO 8601 (subset) year-month";
         } else if (temporalClass == LocalDate.class) {
             dateTimeFormatter = ISO8601_DATE_FORMAT;
+            parserExtendedDateTimeFormatter = PARSER_ISO8601_EXTENDED_DATE_FORMAT;
+            parserBasicDateTimeFormatter = PARSER_ISO8601_BASIC_DATE_FORMAT;
             description = "ISO 8601 (subset) date";
+        } else if (temporalClass == LocalDateTime.class || temporalClass == OffsetDateTime.class
+                || temporalClass == ZonedDateTime.class || temporalClass == Instant.class) {
+            dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
+            parserExtendedDateTimeFormatter = PARSER_ISO8601_EXTENDED_DATE_TIME_FORMAT;
+            parserBasicDateTimeFormatter = PARSER_ISO8601_BASIC_DATE_TIME_FORMAT;
+            description = "ISO 8601 (subset) date-time";
         } else {
-            Class<? extends Temporal> normTemporalClass =
-                    _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
-            if (normTemporalClass != temporalClass) {
-                return getISOFormatter(normTemporalClass, timeZone);
-            } else {
-                dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
-                description = "ISO 8601 (subset) date-time";
-            }
+            throw new BugException();
         }
-        // TODO [FREEMARKER-35] What about date-only?
-        return new ISOLikeTemplateTemporalTemporalFormat(dateTimeFormatter, temporalClass, timeZone, description);
+        return new ISOLikeTemplateTemporalTemporalFormat(
+                dateTimeFormatter,
+                parserExtendedDateTimeFormatter,
+                parserBasicDateTimeFormatter,
+                temporalClass, timeZone, description);
     }
 
 }
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
index 0e979f2..64e2cfd 100644
--- a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
@@ -36,12 +36,8 @@ import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.time.format.FormatStyle;
 import java.time.temporal.Temporal;
-import java.time.temporal.TemporalAccessor;
 import java.time.temporal.TemporalQuery;
-import java.util.IdentityHashMap;
 import java.util.Locale;
-import java.util.Map;
-import java.util.Objects;
 import java.util.TimeZone;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -49,7 +45,7 @@ import java.util.regex.Pattern;
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
 import freemarker.template.utility.ClassUtil;
-import freemarker.template.utility.DateUtil;
+import freemarker.template.utility.TemporalUtils;
 
 /**
  * See {@link JavaTemplateTemporalFormatFactory}.
@@ -82,20 +78,6 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
     private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile(
             "(?:" + ANY_FORMAT_STYLE + "(?:_" + ANY_FORMAT_STYLE + ")?)?");
 
-    private static final Map<Class<? extends Temporal>, TemporalQuery<? extends Temporal>> TEMPORAL_CLASS_TO_QUERY_MAP;
-    static {
-        TEMPORAL_CLASS_TO_QUERY_MAP = new IdentityHashMap<>();
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(Instant.class, Instant::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDate.class, LocalDate::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDateTime.class, LocalDateTime::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalTime.class, LocalTime::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetDateTime.class, JavaTemplateTemporalFormat::offsetDateTimeFrom);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetTime.class, OffsetTime::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(ZonedDateTime.class, ZonedDateTime::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(Year.class, Year::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(YearMonth.class, YearMonth::from);
-    }
-
     private final DateTimeFormatter dateTimeFormatter;
     private final TemporalQuery<? extends Temporal> temporalQuery;
     private final ZoneId zoneId;
@@ -106,9 +88,9 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
 
     JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone)
             throws InvalidFormatParametersException {
-        this.temporalClass = _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+        this.temporalClass = TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
 
-        temporalQuery = Objects.requireNonNull(TEMPORAL_CLASS_TO_QUERY_MAP.get(temporalClass));
+        temporalQuery = TemporalUtils.getTemporalQuery(temporalClass);
 
         final Matcher formatStylePatternMatcher = FORMAT_STYLE_PATTERN.matcher(formatString);
         final boolean isFormatStyleString = formatStylePatternMatcher.matches();
@@ -147,7 +129,7 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
             timePartFormatStyle = null;
 
             try {
-                dateTimeFormatter = DateUtil.dateTimeFormatterFromSimpleDateFormatPattern(formatString, locale);
+                dateTimeFormatter = TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(formatString, locale);
             } catch (IllegalArgumentException e) {
                 throw new InvalidFormatParametersException(e.getMessage(), e);
             }
@@ -342,10 +324,6 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
                 || normalizedTemporalClass == YearMonth.class;
     }
 
-    private static OffsetDateTime offsetDateTimeFrom(TemporalAccessor temporal) {
-        return ZonedDateTime.from(temporal).toOffsetDateTime();
-    }
-
     private static FormatStyle getMoreVerboseStyle(FormatStyle style) {
         switch (style) {
             case SHORT:
diff --git a/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
index f41c0c5..ab39c97 100644
--- a/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
+++ b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
@@ -21,19 +21,26 @@ package freemarker.core;
 
 import static freemarker.core.ISOTemplateTemporalFormatFactory.*;
 
+import java.time.Instant;
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.time.LocalTime;
+import java.time.OffsetDateTime;
 import java.time.OffsetTime;
 import java.time.Year;
 import java.time.YearMonth;
+import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 import java.time.temporal.Temporal;
 import java.util.Locale;
 import java.util.TimeZone;
 
+import freemarker.template.utility.TemporalUtils;
+
 /**
  * Format factory related to {@link someJava8Temporal?string.xs}, {@link someJava8Temporal?string.xs_...}, etc.
  */
+// TODO [FREEMARKER-35] Historical date handling compared to ISO
 class XSTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
 
     static final XSTemplateTemporalFormatFactory INSTANCE = new XSTemplateTemporalFormatFactory();
@@ -55,30 +62,38 @@ class XSTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
 
     private static ISOLikeTemplateTemporalTemporalFormat getXSFormatter(Class<? extends Temporal> temporalClass, TimeZone timeZone) {
         final DateTimeFormatter dateTimeFormatter;
+        final DateTimeFormatter parserDateTimeFormatter;
         final String description;
+        temporalClass = TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
         if (temporalClass == LocalTime.class || temporalClass == OffsetTime.class) {
             dateTimeFormatter = ISO8601_TIME_FORMAT;
+            parserDateTimeFormatter = PARSER_ISO8601_EXTENDED_TIME_FORMAT;
             description = "W3C XML Schema time";
         } else if (temporalClass == Year.class) {
             dateTimeFormatter = ISO8601_YEAR_FORMAT;
+            parserDateTimeFormatter = ISO8601_YEAR_FORMAT;
             description = "W3C XML Schema year";
         } else if (temporalClass == YearMonth.class) {
-            dateTimeFormatter = ISO8601_YEARMONTH_FORMAT;
+            dateTimeFormatter = ISO8601_YEAR_MONTH_FORMAT;
+            parserDateTimeFormatter = PARSER_ISO8601_EXTENDED_YEAR_MONTH_FORMAT;
             description = "W3C XML Schema year-month";
         } else if (temporalClass == LocalDate.class) {
             dateTimeFormatter = ISO8601_DATE_FORMAT;
+            parserDateTimeFormatter = PARSER_ISO8601_EXTENDED_DATE_FORMAT;
             description = "W3C XML Schema date";
+        } else if (temporalClass == LocalDateTime.class || temporalClass == OffsetDateTime.class
+                || temporalClass == ZonedDateTime.class || temporalClass == Instant.class) {
+            dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
+            parserDateTimeFormatter = PARSER_ISO8601_EXTENDED_DATE_TIME_FORMAT;
+            description = "W3C XML Schema date-time";
         } else {
-            Class<? extends Temporal> normTemporalClass =
-                    _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
-            if (normTemporalClass != temporalClass) {
-                return getXSFormatter(normTemporalClass, timeZone);
-            } else {
-                dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
-                description = "W3C XML Schema date-time";
-            }
+            throw new BugException();
         }
-        return new ISOLikeTemplateTemporalTemporalFormat(dateTimeFormatter, temporalClass, timeZone, description);
+        return new ISOLikeTemplateTemporalTemporalFormat(
+                dateTimeFormatter,
+                parserDateTimeFormatter,
+                null,
+                temporalClass, timeZone, description);
     }
 
 }
diff --git a/src/main/java/freemarker/core/_CoreTemporalUtils.java b/src/main/java/freemarker/core/_CoreTemporalUtils.java
deleted file mode 100644
index ca1032b..0000000
--- a/src/main/java/freemarker/core/_CoreTemporalUtils.java
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package freemarker.core;
-
-import java.lang.reflect.Modifier;
-import java.time.Instant;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.LocalTime;
-import java.time.OffsetDateTime;
-import java.time.OffsetTime;
-import java.time.Year;
-import java.time.YearMonth;
-import java.time.ZonedDateTime;
-import java.time.temporal.Temporal;
-import java.util.Arrays;
-import java.util.List;
-
-import freemarker.template.Configuration;
-
-/**
- * For internal use only; don't depend on this, there's no backward compatibility guarantee at all!
- * This class is to work around the lack of module system in Java, i.e., so that other FreeMarker packages can
- * access things inside this package that users shouldn't.
- */
-public class _CoreTemporalUtils {
-
-    private _CoreTemporalUtils() {
-        // No meant to be instantiated
-    }
-
-    /**
-     * {@link Temporal} subclasses directly supperted by FreeMarker.
-     */
-    public static final List<Class<? extends Temporal>> SUPPORTED_TEMPORAL_CLASSES = Arrays.asList(
-            Instant.class,
-            LocalDate.class,
-            LocalDateTime.class,
-            LocalTime.class,
-            OffsetDateTime.class,
-            OffsetTime.class,
-            ZonedDateTime.class,
-            Year.class,
-            YearMonth.class);
-
-    static final boolean SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL = SUPPORTED_TEMPORAL_CLASSES.stream()
-            .allMatch(cl -> (cl.getModifiers() & Modifier.FINAL) == Modifier.FINAL);
-
-    /**
-     * Ensures that {@code ==} can be used to check if the class is assignable to one of the {@link Temporal} subclasses
-     * that FreeMarker directly supports. At least in Java 8 they are all final anyway, but just in case this changes in
-     * a future Java version, use this method before using {@code ==}.
-     *
-     * @since 2.3.31
-     */
-    public static Class<? extends Temporal> normalizeSupportedTemporalClass(Class<? extends Temporal> temporalClass) {
-        if (SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL) {
-            return temporalClass;
-        } else {
-            if (Instant.class.isAssignableFrom(temporalClass)) {
-                return Instant.class;
-            } else if (LocalDate.class.isAssignableFrom(temporalClass)) {
-                return LocalDate.class;
-            } else if (LocalDateTime.class.isAssignableFrom(temporalClass)) {
-                return LocalDateTime.class;
-            } else if (LocalTime.class.isAssignableFrom(temporalClass)) {
-                return LocalTime.class;
-            } else if (OffsetDateTime.class.isAssignableFrom(temporalClass)) {
-                return OffsetDateTime.class;
-            } else if (OffsetTime.class.isAssignableFrom(temporalClass)) {
-                return OffsetTime.class;
-            } else if (ZonedDateTime.class.isAssignableFrom(temporalClass)) {
-                return ZonedDateTime.class;
-            } else if (YearMonth.class.isAssignableFrom(temporalClass)) {
-                return YearMonth.class;
-            } else if (Year.class.isAssignableFrom(temporalClass)) {
-                return Year.class;
-            } else {
-                return temporalClass;
-            }
-        }
-    }
-
-    /**
-     * @throws IllegalArgumentException If {@link temporalClass} is not a supported {@link Temporal} subclass.
-     */
-    public static String temporalClassToFormatSettingName(Class<? extends Temporal> temporalClass) {
-        temporalClass = normalizeSupportedTemporalClass(temporalClass);
-        if (temporalClass == Instant.class
-                || temporalClass == LocalDateTime.class
-                || temporalClass == ZonedDateTime.class
-                || temporalClass == OffsetDateTime.class) {
-            return Configuration.DATETIME_FORMAT_KEY;
-        } else if (temporalClass == LocalDate.class) {
-            return Configuration.DATE_FORMAT_KEY;
-        } else if (temporalClass == LocalTime.class || temporalClass == OffsetTime.class) {
-            return Configuration.TIME_FORMAT_KEY;
-        } else if (temporalClass == YearMonth.class) {
-            return Configuration.YEAR_MONTH_FORMAT_KEY;
-        } else if (temporalClass == Year.class) {
-            return Configuration.YEAR_FORMAT_KEY;
-        } else {
-            throw new IllegalArgumentException("Unsupported temporal class: " + temporalClass.getName());
-        }
-    }
-
-}
diff --git a/src/main/java/freemarker/template/Template.java b/src/main/java/freemarker/template/Template.java
index 578f48d..f71afd3 100644
--- a/src/main/java/freemarker/template/Template.java
+++ b/src/main/java/freemarker/template/Template.java
@@ -668,7 +668,7 @@ public class Template extends Configurable {
     /**
      * Returns the naming convention the parser has chosen for this template. If it could be determined, it's
      * {@link Configuration#LEGACY_NAMING_CONVENTION} or {@link Configuration#CAMEL_CASE_NAMING_CONVENTION}. If it
-     * couldn't be determined (like because there no identifier that's part of the template language was used where
+     * couldn't be determined (like because no identifier that's part of the template language was used where
      * the naming convention matters), this returns whatever the default is in the current configuration, so it's maybe
      * {@link Configuration#AUTO_DETECT_TAG_SYNTAX}.
      * 
diff --git a/src/main/java/freemarker/template/utility/DateUtil.java b/src/main/java/freemarker/template/utility/DateUtil.java
index 4023e21..a3d5020 100644
--- a/src/main/java/freemarker/template/utility/DateUtil.java
+++ b/src/main/java/freemarker/template/utility/DateUtil.java
@@ -20,16 +20,6 @@
 package freemarker.template.utility;
 
 import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.time.chrono.Chronology;
-import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeFormatterBuilder;
-import java.time.format.DecimalStyle;
-import java.time.format.SignStyle;
-import java.time.format.TextStyle;
-import java.time.temporal.ChronoField;
-import java.time.temporal.TemporalField;
-import java.time.temporal.WeekFields;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.GregorianCalendar;
@@ -38,8 +28,6 @@ import java.util.TimeZone;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-import freemarker.core._JavaTimeBugUtils;
-
 /**
  * Date and time related utilities.
  */
@@ -686,7 +674,7 @@ public class DateUtil {
             int millisecs = groupToMillisecond(m.group(7));
             
             // As a time is just the distance from the beginning of the day,
-            // the time-zone offest should be 0 usually.
+            // the time-zone offset should be 0 usually.
             TimeZone tz = parseMatchingTimeZone(m.group(8), defaultTZ);
             
             // Continue handling the 24:00 specail case
@@ -819,320 +807,6 @@ public class DateUtil {
     }
 
     /**
-     * Creates a {@link DateTimeFormatter} from a pattern that uses the syntax that's used by the
-     * {@link SimpleDateFormat} constructor.
-     *
-     * @param pattern The pattern with {@link SimpleDateFormat} syntax.
-     * @param locale The locale of the output of the formatter
-     *
-     * @return
-     *
-     * @throws IllegalArgumentException If the pattern is not a valid {@link SimpleDateFormat} pattern (based on the
-     * syntax documented for Java 15).
-     */
-    public static DateTimeFormatter dateTimeFormatterFromSimpleDateFormatPattern(String pattern, Locale locale) {
-        return createDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale)
-                .toFormatter(locale)
-                .withDecimalStyle(DecimalStyle.of(locale))
-                .withChronology(getChronologyForLocaleWithLegacyRules(locale));
-    }
-
-    private static DateTimeFormatterBuilder createDateTimeFormatterBuilderFromSimpleDateFormatPattern(
-            String pattern, Locale locale) {
-        DateTimeFormatterBuilder builder = tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, false);
-        if (builder == null) {
-            builder = tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, true);
-        }
-        return builder;
-    }
-
-    /**
-     * @param standaloneFormGuess Guess if we only will have one field.
-     * @return If {@code null}, then {@code standaloneFormGuess} was wrong, and it also mattered, so retry with the
-     *         inverse of it.
-     */
-    private static DateTimeFormatterBuilder tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(
-            String pattern, Locale locale, boolean standaloneFormGuess) {
-        DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
-
-        builder.parseCaseInsensitive(); // Must be before pattern(s) appended!
-
-        int numberOfFields = 0;
-        int len = pattern.length();
-        int pos = 0;
-        int lastClosingQuotePos = Integer.MIN_VALUE;
-        boolean standaloneFormGuessWasUsed = false;
-        do {
-            char c = pos < len ? pattern.charAt(pos++) : 0;
-            if (isAsciiLetter(c)) {
-                int startPos = pos - 1;
-                while (pos < len && pattern.charAt(pos) == c) {
-                    pos++;
-                }
-                standaloneFormGuessWasUsed |= applyRepeatedLetter(
-                        c, pos - startPos, locale, pattern, standaloneFormGuess, builder);
-                numberOfFields++;
-            } else if (c == '\'') {
-                int literalStartPos = pos;
-                if (lastClosingQuotePos == literalStartPos - 2) {
-                    builder.appendLiteral('\'');
-                }
-                while (pos < len && pattern.charAt(pos) != '\'') {
-                    pos++;
-                }
-                if (literalStartPos == pos) {
-                    builder.appendLiteral('\'');
-                    // Doesn't set lastClosingQuotePos
-                } else {
-                    builder.appendLiteral(pattern.substring(literalStartPos, pos));
-                    lastClosingQuotePos = pos;
-                }
-                pos++; // Because char at pos was already processed
-            } else {
-                int literalStartPos = pos - 1;
-                while (pos < len && !isAsciiLetterOrApostrophe(pattern.charAt(pos))) {
-                    pos++;
-                }
-                builder.appendLiteral(pattern.substring(literalStartPos, pos));
-                // No pos++, because the char at pos is not yet processed
-            }
-        } while (pos < len);
-        if (standaloneFormGuessWasUsed && standaloneFormGuess != (numberOfFields == 1)) {
-            return null;
-        }
-        return builder;
-    }
-
-    private static boolean applyRepeatedLetter(
-            char c, int width, Locale locale, String pattern,
-            boolean standaloneField,
-            DateTimeFormatterBuilder builder) {
-        boolean standaloneFieldArgWasUsed = false;
-        switch (c) {
-            case 'y':
-                appendYearLike(width, ChronoField.YEAR_OF_ERA, builder);
-                break;
-            case 'Y':
-                appendYearLike(width, WeekFields.of(locale).weekBasedYear(), builder);
-                break;
-            case 'M':
-            case 'L':
-                if (width <= 2) {
-                    appendValueWithSafeWidth(ChronoField.MONTH_OF_YEAR, width, 2, builder);
-                } else if (width == 3) {
-                    TextStyle textStyle;
-                    if (c == 'M') {
-                        standaloneFieldArgWasUsed = true;
-                        textStyle = standaloneField ? TextStyle.SHORT_STANDALONE : TextStyle.SHORT;
-                    } else {
-                        textStyle = TextStyle.SHORT_STANDALONE;
-                    }
-
-                    if (textStyle == TextStyle.SHORT_STANDALONE
-                            && !_JavaTimeBugUtils.hasGoodShortStandaloneMonth(locale)) {
-                        textStyle = TextStyle.SHORT;
-                    }
-
-                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
-                } else {
-                    TextStyle textStyle;
-                    if (c == 'M') {
-                        standaloneFieldArgWasUsed = true;
-                        textStyle = standaloneField ? TextStyle.FULL_STANDALONE : TextStyle.FULL;
-                    } else {
-                        textStyle = TextStyle.FULL_STANDALONE;
-                    }
-
-                    if (textStyle == TextStyle.FULL_STANDALONE
-                            && !_JavaTimeBugUtils.hasGoodFullStandaloneMonth(locale)) {
-                        textStyle = TextStyle.FULL;
-                    }
-
-                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
-                }
-                break;
-            case 'd':
-                appendValueWithSafeWidth(ChronoField.DAY_OF_MONTH, width, 2, builder);
-                break;
-            case 'D':
-                if (width == 1) {
-                    builder.appendValue(ChronoField.DAY_OF_YEAR);
-                } else if (width == 2) {
-                    // 2 wide if possible, but don't lose a digit over 99. SimpleDateFormat does this too.
-                    builder.appendValue(ChronoField.DAY_OF_YEAR, 2, 3, SignStyle.NOT_NEGATIVE);
-                } else {
-                    // Here width is at least 3, so we are safe.
-                    builder.appendValue(ChronoField.DAY_OF_YEAR, width);
-                }
-                break;
-            case 'h':
-                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_AMPM, width, 2, builder);
-                break;
-            case 'H':
-                appendValueWithSafeWidth(ChronoField.HOUR_OF_DAY, width, 2, builder);
-                break;
-            case 'k':
-                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_DAY, width, 2, builder);
-                break;
-            case 'K':
-                appendValueWithSafeWidth(ChronoField.HOUR_OF_AMPM, width, 2, builder);
-                break;
-            case 'a':
-                // From experimentation with SimpleDataFormat it seemed that the number of repetitions doesn't matter.
-                builder.appendText(ChronoField.AMPM_OF_DAY, TextStyle.SHORT);
-                break;
-            case 'm':
-                appendValueWithSafeWidth(ChronoField.MINUTE_OF_HOUR, width, 2, builder);
-                break;
-            case 's':
-                appendValueWithSafeWidth(ChronoField.SECOND_OF_MINUTE, width, 2, builder);
-                break;
-            case 'S':
-                // This is quite dangerous, like "s.SS" gives misleading output, but SimpleDateFormat does this.
-                appendValueWithSafeWidth(ChronoField.MILLI_OF_SECOND, width, 3, builder);
-                break;
-            case 'u':
-                builder.appendValue(ChronoField.DAY_OF_WEEK, width);
-                break;
-            case 'w':
-                appendValueWithSafeWidth(WeekFields.of(locale).weekOfWeekBasedYear(), width, 2, builder);
-                break;
-            case 'W':
-                appendValueWithSafeWidth(WeekFields.of(locale).weekOfMonth(), width, 1, builder);
-                break;
-            case 'E':
-                if (width <= 3 ) {
-                    builder.appendText(ChronoField.DAY_OF_WEEK, TextStyle.SHORT);
-                } else {
-                    builder.appendText(ChronoField.DAY_OF_WEEK, TextStyle.FULL);
-                }
-                break;
-            case 'G':
-                // Width apparently doesn't matter for SimpleDateFormat, and we mimic that. (It's not always a perfect
-                // match though, like japanese calendar era "Reiwa" VS "R".)
-                builder.appendText(ChronoField.ERA, TextStyle.SHORT);
-                break;
-            case 'F':
-                // While SimpleDateFormat documentation says it's "day of week in month", the actual output is "aligned
-                // week of month" (a bug, I assume). With DateTimeFormatter "F" is "aligned day of week in month", but
-                // our goal here is to mimic the behaviour of SimpleDateFormat.
-                appendValueWithSafeWidth(ChronoField.ALIGNED_WEEK_OF_MONTH, width, 1, builder);
-                break;
-            case 'z':
-                if (width < 4) {
-                    builder.appendZoneText(TextStyle.SHORT);
-                } else {
-                    builder.appendZoneText(TextStyle.FULL);
-                }
-                break;
-            case 'Z':
-                // Width apparently doesn't matter for SimpleDateFormat, and we mimic that.
-                builder.appendOffset("+HHMM","+0000");
-                break;
-            case 'X':
-                if (width == 1) {
-                    // We lose the minutes here, just like SimpleDateFormat did.
-                    builder.appendOffset("+HH", "Z");
-                } else if (width == 2) {
-                    builder.appendOffset("+HHMM", "Z");
-                } else if (width == 3) {
-                    builder.appendOffset("+HH:MM", "Z");
-                } else {
-                    throw new IllegalArgumentException("Can't create DateTimeFormatter from SimpleDateFormat pattern "
-                            + StringUtil.jQuote(pattern) + ": "
-                            + " \"X\" width in SimpleDateFormat patterns must be less than 4.");
-                }
-                break;
-            default:
-                throw new IllegalArgumentException("Can't create DateTimeFormatter from SimpleDateFormat pattern "
-                        + StringUtil.jQuote(pattern) + ": "
-                        + StringUtil.jQuote(c) + " is an invalid or unsupported SimpleDateFormat pattern letter.");
-        }
-        return standaloneFieldArgWasUsed;
-    }
-
-    private static void appendYearLike(int width, TemporalField field, DateTimeFormatterBuilder builder) {
-        if (width != 2) {
-            builder.appendValue(field, width, 19, SignStyle.NORMAL);
-        } else {
-            builder.appendValueReduced(field, 2, 2, 2000);
-        }
-    }
-
-    private static String repeatChar(char c, int count) {
-        char[] chars = new char[count];
-        for (int i = 0; i < count; i++) {
-            chars[i] = c;
-        }
-        return new String(chars);
-    }
-
-    /**
-     * Used for non-negative numerical fields, behaves like {@link SimpleDateFormat} regarding the field width.
-     *
-     * @param width The width specified in the pattern
-     * @param safeWidth The minimum width needed to safely display any valid value
-     */
-    private static void appendValueWithSafeWidth(
-            TemporalField field, int width, int safeWidth, DateTimeFormatterBuilder builder) {
-        builder.appendValue(field, width, width < safeWidth ? safeWidth : width, SignStyle.NOT_NEGATIVE);
-    }
-
-    private static boolean isAsciiLetterOrApostrophe(char c) {
-        return isAsciiLetter(c) || c == '\'';
-    }
-
-    private static boolean isAsciiLetter(char c) {
-        return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
-    }
-
-    /**
-     * Gives the {@link Chronology} for a {@link Locale} that {@link Calendar#getInstance(Locale)} would; except, that
-     * returned a {@link Calendar} instead of a {@link Chronology}, so this is somewhat complicated to do.
-     */
-    private static Chronology getChronologyForLocaleWithLegacyRules(Locale locale) {
-        // Usually null
-        String askedCalendarType = locale.getUnicodeLocaleType("ca");
-
-        Calendar calendar = Calendar.getInstance(locale);
-
-        Locale chronologyLocale;
-        String legacyLocalizedCalendarType = calendar.getCalendarType();
-        // The pre-java.time API gives different localized defaults sometimes, or at least for th_TH. To be on the safe
-        // side, for the two non-gregory types that pre-java.time Java supported out-of-the-box, we force the calendar
-        // type in the Locale, for which later we will ask the Chronology.
-        if (("buddhist".equals(legacyLocalizedCalendarType) || "japanese".equals(legacyLocalizedCalendarType))
-                && !legacyLocalizedCalendarType.equals(askedCalendarType)) {
-            chronologyLocale = createLocaleWithCalendarType(
-                    locale,
-                    legacyCalendarTypeToJavaTimeApiCompatibleName(legacyLocalizedCalendarType));
-        } else {
-            // Even if there's no difference in the default chronology of the locale, the calendar type names that
-            // worked with the legacy API might not be recognized by the java.time API.
-            String compatibleAskedCalendarType = legacyCalendarTypeToJavaTimeApiCompatibleName(askedCalendarType);
-            if (askedCalendarType != compatibleAskedCalendarType) { // deliberately doesn't use equals(...)
-                chronologyLocale = createLocaleWithCalendarType(locale, compatibleAskedCalendarType);
-            } else {
-                chronologyLocale = locale;
-            }
-        }
-        Chronology chronology = Chronology.ofLocale(chronologyLocale);
-        return chronology;
-    }
-
-    private static String legacyCalendarTypeToJavaTimeApiCompatibleName(String legacyType) {
-        // "gregory" is the Calendar.calendarType in the old API, but Chronology.ofLocale calls it "ISO".
-        return "gregory".equals(legacyType) ? "ISO" : legacyType;
-    }
-
-    private static Locale createLocaleWithCalendarType(Locale locale, String legacyApiCalendarType) {
-        return new Locale.Builder()
-                .setLocale(locale)
-                .setUnicodeLocaleKeyword("ca", legacyApiCalendarType)
-                .build();
-    }
-
-    /**
      * Used internally by {@link DateUtil}; don't use its implementations for
      * anything else.
      */
diff --git a/src/main/java/freemarker/template/utility/StringUtil.java b/src/main/java/freemarker/template/utility/StringUtil.java
index 3317955..0bc0a6a 100644
--- a/src/main/java/freemarker/template/utility/StringUtil.java
+++ b/src/main/java/freemarker/template/utility/StringUtil.java
@@ -2156,5 +2156,13 @@ public class StringUtil {
         }
         return sb.toString();
     }
-    
+
+    /**
+     * Tells if the char is a US-ASCII letter.
+     *
+     * @since 2.3.32
+     */
+    public static boolean isUsAsciiLetter(char c) {
+        return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
+    }
 }
diff --git a/src/main/java/freemarker/template/utility/TemporalUtils.java b/src/main/java/freemarker/template/utility/TemporalUtils.java
new file mode 100644
index 0000000..fb82e85
--- /dev/null
+++ b/src/main/java/freemarker/template/utility/TemporalUtils.java
@@ -0,0 +1,499 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.template.utility;
+
+import java.lang.reflect.Modifier;
+import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.Year;
+import java.time.YearMonth;
+import java.time.ZonedDateTime;
+import java.time.chrono.Chronology;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DecimalStyle;
+import java.time.format.SignStyle;
+import java.time.format.TextStyle;
+import java.time.temporal.ChronoField;
+import java.time.temporal.Temporal;
+import java.time.temporal.TemporalAccessor;
+import java.time.temporal.TemporalField;
+import java.time.temporal.TemporalQuery;
+import java.time.temporal.WeekFields;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import freemarker.core._JavaTimeBugUtils;
+import freemarker.template.Configuration;
+
+/**
+ * Static utilities related to {@link Temporal}-s, and other {@code java.time} classes.
+ *
+ * @since 2.3.32
+ */
+public final class TemporalUtils {
+    private static final Map<Class<? extends Temporal>, TemporalQuery<? extends Temporal>> TEMPORAL_CLASS_TO_QUERY_MAP;
+    static {
+        TEMPORAL_CLASS_TO_QUERY_MAP = new IdentityHashMap<>();
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(Instant.class, Instant::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDate.class, LocalDate::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDateTime.class, LocalDateTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalTime.class, LocalTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetDateTime.class, TemporalUtils::offsetDateTimeFrom);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetTime.class, OffsetTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(ZonedDateTime.class, ZonedDateTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(Year.class, Year::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(YearMonth.class, YearMonth::from);
+    }
+
+    /**
+     * {@link Temporal} subclasses directly suppoerted by FreeMarker.
+     */
+    static final List<Class<? extends Temporal>> SUPPORTED_TEMPORAL_CLASSES = Arrays.asList(
+            Instant.class,
+            LocalDate.class,
+            LocalDateTime.class,
+            LocalTime.class,
+            OffsetDateTime.class,
+            OffsetTime.class,
+            ZonedDateTime.class,
+            Year.class,
+            YearMonth.class);
+
+    static final boolean SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL = SUPPORTED_TEMPORAL_CLASSES.stream()
+            .allMatch(cl -> (cl.getModifiers() & Modifier.FINAL) == Modifier.FINAL);
+
+    private TemporalUtils() {
+        throw new AssertionError();
+    }
+
+    /**
+     * Creates a temporal query that can be used to create an object of the specified temporal class from a typical
+     * parsing result.
+     */
+    public static TemporalQuery<? extends Temporal> getTemporalQuery(Class<? extends Temporal> temporalClass) {
+        TemporalQuery<? extends Temporal> temporalQuery = TEMPORAL_CLASS_TO_QUERY_MAP.get(temporalClass);
+        if (temporalQuery == null) {
+            Class<? extends Temporal> normalizedTemporalClass = normalizeSupportedTemporalClass(
+                    temporalClass);
+            if (temporalClass != normalizedTemporalClass) {
+                temporalQuery = TEMPORAL_CLASS_TO_QUERY_MAP.get(normalizedTemporalClass);
+            }
+        }
+        if (temporalQuery == null) {
+            throw new IllegalArgumentException("Unsupported temporal class: " + temporalClass.getName());
+        }
+        return temporalQuery;
+    }
+
+    private static OffsetDateTime offsetDateTimeFrom(TemporalAccessor temporal) {
+        return ZonedDateTime.from(temporal).toOffsetDateTime();
+    }
+
+    /**
+     * Creates a {@link DateTimeFormatter} from a pattern that uses the syntax that's used by the
+     * {@link SimpleDateFormat} constructor.
+     *
+     * @param pattern The pattern with {@link SimpleDateFormat} syntax.
+     * @param locale The locale of the output of the formatter
+     *
+     * @return
+     *
+     * @throws IllegalArgumentException If the pattern is not a valid {@link SimpleDateFormat} pattern (based on the
+     * syntax documented for Java 15).
+     */
+    public static DateTimeFormatter dateTimeFormatterFromSimpleDateFormatPattern(String pattern, Locale locale) {
+        return createDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale)
+                .toFormatter(locale)
+                .withDecimalStyle(DecimalStyle.of(locale))
+                .withChronology(getChronologyForLocaleWithLegacyRules(locale));
+    }
+
+    private static DateTimeFormatterBuilder createDateTimeFormatterBuilderFromSimpleDateFormatPattern(
+            String pattern, Locale locale) {
+        DateTimeFormatterBuilder builder = tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, false);
+        if (builder == null) {
+            builder = tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, true);
+        }
+        return builder;
+    }
+
+    /**
+     * @param standaloneFormGuess Guess if we only will have one field.
+     * @return If {@code null}, then {@code standaloneFormGuess} was wrong, and it also mattered, so retry with the
+     *         inverse of it.
+     */
+    private static DateTimeFormatterBuilder tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(
+            String pattern, Locale locale, boolean standaloneFormGuess) {
+        DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
+
+        builder.parseCaseInsensitive(); // Must be before pattern(s) appended!
+
+        int numberOfFields = 0;
+        int len = pattern.length();
+        int pos = 0;
+        int lastClosingQuotePos = Integer.MIN_VALUE;
+        boolean standaloneFormGuessWasUsed = false;
+        do {
+            char c = pos < len ? pattern.charAt(pos++) : 0;
+            if (StringUtil.isUsAsciiLetter(c)) {
+                int startPos = pos - 1;
+                while (pos < len && pattern.charAt(pos) == c) {
+                    pos++;
+                }
+                standaloneFormGuessWasUsed |= applyRepeatedLetter(
+                        c, pos - startPos, locale, pattern, standaloneFormGuess, builder);
+                numberOfFields++;
+            } else if (c == '\'') {
+                int literalStartPos = pos;
+                if (lastClosingQuotePos == literalStartPos - 2) {
+                    builder.appendLiteral('\'');
+                }
+                while (pos < len && pattern.charAt(pos) != '\'') {
+                    pos++;
+                }
+                if (literalStartPos == pos) {
+                    builder.appendLiteral('\'');
+                    // Doesn't set lastClosingQuotePos
+                } else {
+                    builder.appendLiteral(pattern.substring(literalStartPos, pos));
+                    lastClosingQuotePos = pos;
+                }
+                pos++; // Because char at pos was already processed
+            } else {
+                int literalStartPos = pos - 1;
+                while (pos < len && !isUsAsciiLetterOrApostrophe(pattern.charAt(pos))) {
+                    pos++;
+                }
+                builder.appendLiteral(pattern.substring(literalStartPos, pos));
+                // No pos++, because the char at pos is not yet processed
+            }
+        } while (pos < len);
+        if (standaloneFormGuessWasUsed && standaloneFormGuess != (numberOfFields == 1)) {
+            return null;
+        }
+        return builder;
+    }
+
+    private static boolean applyRepeatedLetter(
+            char c, int width, Locale locale, String pattern,
+            boolean standaloneField,
+            DateTimeFormatterBuilder builder) {
+        boolean standaloneFieldArgWasUsed = false;
+        switch (c) {
+            case 'y':
+                appendYearLike(width, ChronoField.YEAR_OF_ERA, builder);
+                break;
+            case 'Y':
+                appendYearLike(width, WeekFields.of(locale).weekBasedYear(), builder);
+                break;
+            case 'M':
+            case 'L':
+                if (width <= 2) {
+                    appendValueWithSafeWidth(ChronoField.MONTH_OF_YEAR, width, 2, builder);
+                } else if (width == 3) {
+                    TextStyle textStyle;
+                    if (c == 'M') {
+                        standaloneFieldArgWasUsed = true;
+                        textStyle = standaloneField ? TextStyle.SHORT_STANDALONE : TextStyle.SHORT;
+                    } else {
+                        textStyle = TextStyle.SHORT_STANDALONE;
+                    }
+
+                    if (textStyle == TextStyle.SHORT_STANDALONE
+                            && !_JavaTimeBugUtils.hasGoodShortStandaloneMonth(locale)) {
+                        textStyle = TextStyle.SHORT;
+                    }
+
+                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
+                } else {
+                    TextStyle textStyle;
+                    if (c == 'M') {
+                        standaloneFieldArgWasUsed = true;
+                        textStyle = standaloneField ? TextStyle.FULL_STANDALONE : TextStyle.FULL;
+                    } else {
+                        textStyle = TextStyle.FULL_STANDALONE;
+                    }
+
+                    if (textStyle == TextStyle.FULL_STANDALONE
+                            && !_JavaTimeBugUtils.hasGoodFullStandaloneMonth(locale)) {
+                        textStyle = TextStyle.FULL;
+                    }
+
+                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
+                }
+                break;
+            case 'd':
+                appendValueWithSafeWidth(ChronoField.DAY_OF_MONTH, width, 2, builder);
+                break;
+            case 'D':
+                if (width == 1) {
+                    builder.appendValue(ChronoField.DAY_OF_YEAR);
+                } else if (width == 2) {
+                    // 2 wide if possible, but don't lose a digit over 99. SimpleDateFormat does this too.
+                    builder.appendValue(ChronoField.DAY_OF_YEAR, 2, 3, SignStyle.NOT_NEGATIVE);
+                } else {
+                    // Here width is at least 3, so we are safe.
+                    builder.appendValue(ChronoField.DAY_OF_YEAR, width);
+                }
+                break;
+            case 'h':
+                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_AMPM, width, 2, builder);
+                break;
+            case 'H':
+                appendValueWithSafeWidth(ChronoField.HOUR_OF_DAY, width, 2, builder);
+                break;
+            case 'k':
+                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_DAY, width, 2, builder);
+                break;
+            case 'K':
+                appendValueWithSafeWidth(ChronoField.HOUR_OF_AMPM, width, 2, builder);
+                break;
+            case 'a':
+                // From experimentation with SimpleDataFormat it seemed that the number of repetitions doesn't matter.
+                builder.appendText(ChronoField.AMPM_OF_DAY, TextStyle.SHORT);
+                break;
+            case 'm':
+                appendValueWithSafeWidth(ChronoField.MINUTE_OF_HOUR, width, 2, builder);
+                break;
+            case 's':
+                appendValueWithSafeWidth(ChronoField.SECOND_OF_MINUTE, width, 2, builder);
+                break;
+            case 'S':
+                // This is quite dangerous, like "s.SS" gives misleading output, but SimpleDateFormat does this.
+                appendValueWithSafeWidth(ChronoField.MILLI_OF_SECOND, width, 3, builder);
+                break;
+            case 'u':
+                builder.appendValue(ChronoField.DAY_OF_WEEK, width);
+                break;
+            case 'w':
+                appendValueWithSafeWidth(WeekFields.of(locale).weekOfWeekBasedYear(), width, 2, builder);
+                break;
+            case 'W':
+                appendValueWithSafeWidth(WeekFields.of(locale).weekOfMonth(), width, 1, builder);
+                break;
+            case 'E':
+                if (width <= 3 ) {
+                    builder.appendText(ChronoField.DAY_OF_WEEK, TextStyle.SHORT);
+                } else {
+                    builder.appendText(ChronoField.DAY_OF_WEEK, TextStyle.FULL);
+                }
+                break;
+            case 'G':
+                // Width apparently doesn't matter for SimpleDateFormat, and we mimic that. (It's not always a perfect
+                // match though, like japanese calendar era "Reiwa" VS "R".)
+                builder.appendText(ChronoField.ERA, TextStyle.SHORT);
+                break;
+            case 'F':
+                // While SimpleDateFormat documentation says it's "day of week in month", the actual output is "aligned
+                // week of month" (a bug, I assume). With DateTimeFormatter "F" is "aligned day of week in month", but
+                // our goal here is to mimic the behaviour of SimpleDateFormat.
+                appendValueWithSafeWidth(ChronoField.ALIGNED_WEEK_OF_MONTH, width, 1, builder);
+                break;
+            case 'z':
+                if (width < 4) {
+                    builder.appendZoneText(TextStyle.SHORT);
+                } else {
+                    builder.appendZoneText(TextStyle.FULL);
+                }
+                break;
+            case 'Z':
+                // Width apparently doesn't matter for SimpleDateFormat, and we mimic that.
+                builder.appendOffset("+HHMM","+0000");
+                break;
+            case 'X':
+                if (width == 1) {
+                    // We lose the minutes here, just like SimpleDateFormat did.
+                    builder.appendOffset("+HH", "Z");
+                } else if (width == 2) {
+                    builder.appendOffset("+HHMM", "Z");
+                } else if (width == 3) {
+                    builder.appendOffset("+HH:MM", "Z");
+                } else {
+                    throw new IllegalArgumentException("Can't create DateTimeFormatter from SimpleDateFormat pattern "
+                            + StringUtil.jQuote(pattern) + ": "
+                            + " \"X\" width in SimpleDateFormat patterns must be less than 4.");
+                }
+                break;
+            default:
+                throw new IllegalArgumentException("Can't create DateTimeFormatter from SimpleDateFormat pattern "
+                        + StringUtil.jQuote(pattern) + ": "
+                        + StringUtil.jQuote(c) + " is an invalid or unsupported SimpleDateFormat pattern letter.");
+        }
+        return standaloneFieldArgWasUsed;
+    }
+
+    private static void appendYearLike(int width, TemporalField field, DateTimeFormatterBuilder builder) {
+        if (width != 2) {
+            builder.appendValue(field, width, 19, SignStyle.NORMAL);
+        } else {
+            builder.appendValueReduced(field, 2, 2, 2000);
+        }
+    }
+
+    private static String repeatChar(char c, int count) {
+        char[] chars = new char[count];
+        for (int i = 0; i < count; i++) {
+            chars[i] = c;
+        }
+        return new String(chars);
+    }
+
+    /**
+     * Used for non-negative numerical fields, behaves like {@link SimpleDateFormat} regarding the field width.
+     *
+     * @param width The width specified in the pattern
+     * @param safeWidth The minimum width needed to safely display any valid value
+     */
+    private static void appendValueWithSafeWidth(
+            TemporalField field, int width, int safeWidth, DateTimeFormatterBuilder builder) {
+        builder.appendValue(field, width, width < safeWidth ? safeWidth : width, SignStyle.NOT_NEGATIVE);
+    }
+
+    private static boolean isUsAsciiLetterOrApostrophe(char c) {
+        return StringUtil.isUsAsciiLetter(c) || c == '\'';
+    }
+
+    /**
+     * Gives the {@link Chronology} for a {@link Locale} that {@link Calendar#getInstance(Locale)} would; except, that
+     * returned a {@link Calendar} instead of a {@link Chronology}, so this is somewhat complicated to do.
+     */
+    private static Chronology getChronologyForLocaleWithLegacyRules(Locale locale) {
+        // Usually null
+        String askedCalendarType = locale.getUnicodeLocaleType("ca");
+
+        Calendar calendar = Calendar.getInstance(locale);
+
+        Locale chronologyLocale;
+        String legacyLocalizedCalendarType = calendar.getCalendarType();
+        // The pre-java.time API gives different localized defaults sometimes, or at least for th_TH. To be on the safe
+        // side, for the two non-gregory types that pre-java.time Java supported out-of-the-box, we force the calendar
+        // type in the Locale, for which later we will ask the Chronology.
+        if (("buddhist".equals(legacyLocalizedCalendarType) || "japanese".equals(legacyLocalizedCalendarType))
+                && !legacyLocalizedCalendarType.equals(askedCalendarType)) {
+            chronologyLocale = createLocaleWithCalendarType(
+                    locale,
+                    legacyCalendarTypeToJavaTimeApiCompatibleName(legacyLocalizedCalendarType));
+        } else {
+            // Even if there's no difference in the default chronology of the locale, the calendar type names that
+            // worked with the legacy API might not be recognized by the java.time API.
+            String compatibleAskedCalendarType = legacyCalendarTypeToJavaTimeApiCompatibleName(askedCalendarType);
+            if (askedCalendarType != compatibleAskedCalendarType) { // deliberately doesn't use equals(...)
+                chronologyLocale = createLocaleWithCalendarType(locale, compatibleAskedCalendarType);
+            } else {
+                chronologyLocale = locale;
+            }
+        }
+        Chronology chronology = Chronology.ofLocale(chronologyLocale);
+        return chronology;
+    }
+
+    private static String legacyCalendarTypeToJavaTimeApiCompatibleName(String legacyType) {
+        // "gregory" is the Calendar.calendarType in the old API, but Chronology.ofLocale calls it "ISO".
+        return "gregory".equals(legacyType) ? "ISO" : legacyType;
+    }
+
+    private static Locale createLocaleWithCalendarType(Locale locale, String legacyApiCalendarType) {
+        return new Locale.Builder()
+                .setLocale(locale)
+                .setUnicodeLocaleKeyword("ca", legacyApiCalendarType)
+                .build();
+    }
+
+    /**
+     * Ensures that {@code ==} can be used to check if the class is assignable to one of the {@link Temporal} subclasses
+     * that FreeMarker directly supports. At least in Java 8 they are all final anyway, but just in case this changes in
+     * a future Java version, use this method before using {@code ==}.
+     *
+     * @since 2.3.32
+     */
+    public static Class<? extends Temporal> normalizeSupportedTemporalClass(Class<? extends Temporal> temporalClass) {
+        if (SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL) {
+            return temporalClass;
+        } else {
+            if (Instant.class.isAssignableFrom(temporalClass)) {
+                return Instant.class;
+            } else if (LocalDate.class.isAssignableFrom(temporalClass)) {
+                return LocalDate.class;
+            } else if (LocalDateTime.class.isAssignableFrom(temporalClass)) {
+                return LocalDateTime.class;
+            } else if (LocalTime.class.isAssignableFrom(temporalClass)) {
+                return LocalTime.class;
+            } else if (OffsetDateTime.class.isAssignableFrom(temporalClass)) {
+                return OffsetDateTime.class;
+            } else if (OffsetTime.class.isAssignableFrom(temporalClass)) {
+                return OffsetTime.class;
+            } else if (ZonedDateTime.class.isAssignableFrom(temporalClass)) {
+                return ZonedDateTime.class;
+            } else if (YearMonth.class.isAssignableFrom(temporalClass)) {
+                return YearMonth.class;
+            } else if (Year.class.isAssignableFrom(temporalClass)) {
+                return Year.class;
+            } else {
+                return temporalClass;
+            }
+        }
+    }
+
+    /**
+     * Returns the FreeMarker configuration format setting name for a temporal class.
+     *
+     * @throws IllegalArgumentException If {@link temporalClass} is not a supported {@link Temporal} subclass.
+     */
+    public static String temporalClassToFormatSettingName(Class<? extends Temporal> temporalClass, boolean camelCase) {
+        temporalClass = normalizeSupportedTemporalClass(temporalClass);
+        if (temporalClass == Instant.class
+                || temporalClass == LocalDateTime.class
+                || temporalClass == ZonedDateTime.class
+                || temporalClass == OffsetDateTime.class) {
+            return camelCase
+                    ? Configuration.DATETIME_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.DATETIME_FORMAT_KEY_SNAKE_CASE;
+        } else if (temporalClass == LocalDate.class) {
+            return camelCase
+                    ? Configuration.DATE_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.DATE_FORMAT_KEY_SNAKE_CASE;
+        } else if (temporalClass == LocalTime.class || temporalClass == OffsetTime.class) {
+            return camelCase
+                    ? Configuration.TIME_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.TIME_FORMAT_KEY_SNAKE_CASE;
+        } else if (temporalClass == YearMonth.class) {
+            return camelCase
+                    ? Configuration.YEAR_MONTH_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.YEAR_MONTH_FORMAT_KEY_SNAKE_CASE;
+        } else if (temporalClass == Year.class) {
+            return camelCase
+                    ? Configuration.YEAR_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.YEAR_FORMAT_KEY_SNAKE_CASE;
+        } else {
+            throw new IllegalArgumentException("Unsupported temporal class: " + temporalClass.getName());
+        }
+    }
+
+}
diff --git a/src/test/java/freemarker/core/AbstractTemporalFormatTest.java b/src/test/java/freemarker/core/AbstractTemporalFormatTest.java
new file mode 100644
index 0000000..5f30bb3
--- /dev/null
+++ b/src/test/java/freemarker/core/AbstractTemporalFormatTest.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import static freemarker.template.utility.StringUtil.*;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.function.Consumer;
+
+import freemarker.template.Configuration;
+import freemarker.template.SimpleTemporal;
+import freemarker.template.Template;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateTemporalModel;
+import freemarker.template.utility.ClassUtil;
+import freemarker.template.utility.DateUtil;
+
+/**
+ * For {@link Environment}-level tests related to {@link TemplateTemporalFormat}-s.
+ */
+public abstract class AbstractTemporalFormatTest {
+
+    static protected String formatTemporal(Consumer<Configurable> configurer, Temporal... values) throws
+            TemplateException {
+        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
+
+        configurer.accept(conf);
+
+        Environment env = null;
+        try {
+            env = new Template(null, "", conf).createProcessingEnvironment(null, null);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+
+        StringBuilder sb = new StringBuilder();
+        for (Temporal value : values) {
+            if (sb.length() != 0) {
+                sb.append(", ");
+            }
+            sb.append(env.formatTemporalToPlainText(new SimpleTemporal(value), null, false));
+        }
+
+        return sb.toString();
+    }
+
+    static protected void assertParsingResults(
+            Consumer<Configurable> configurer,
+            Object... stringsAndExpectedResults) throws TemplateException, TemplateValueFormatException {
+        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
+        conf.setTimeZone(DateUtil.UTC);
+        conf.setLocale(Locale.US);
+
+        configurer.accept(conf);
+
+        Environment env = null;
+        try {
+            env = new Template(null, "", conf).createProcessingEnvironment(null, null);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+
+        if (stringsAndExpectedResults.length % 2 != 0) {
+            throw new IllegalArgumentException(
+                    "stringsAndExpectedResults.length must be even, but was " + stringsAndExpectedResults.length + ".");
+        }
+        for (int i = 0; i < stringsAndExpectedResults.length; i += 2) {
+            Object value = stringsAndExpectedResults[i];
+            if (!(value instanceof String)) {
+                throw new IllegalArgumentException("stringsAndExpectedResults[" + i + "] should be a String");
+            }
+            String string = (String) value;
+
+            value = stringsAndExpectedResults[i + 1];
+            if (!(value instanceof Temporal)) {
+                throw new IllegalArgumentException("stringsAndExpectedResults[" + (i + 1) + "] should be a Temporal");
+            }
+            Temporal expectedResult = (Temporal) value;
+
+            Class<? extends Temporal> temporalClass = expectedResult.getClass();
+            TemplateTemporalFormat templateTemporalFormat = env.getTemplateTemporalFormat(temporalClass);
+
+            Temporal actualResult;
+            {
+                Object actualResultObject = templateTemporalFormat.parse(string);
+                if (actualResultObject instanceof Temporal) {
+                    actualResult = (Temporal) actualResultObject;
+                } else if (actualResultObject instanceof TemplateTemporalModel) {
+                    actualResult = ((TemplateTemporalModel) actualResultObject).getAsTemporal();
+                } else {
+                    throw new AssertionError(
+                            "Parsing result of " + jQuote(string) + " is not of an expected type: "
+                                    + ClassUtil.getShortClassNameOfObject(actualResultObject));
+                }
+            }
+
+            if (!expectedResult.equals(actualResult)) {
+                throw new AssertionError(
+                        "Parsing result of " + jQuote(string) + " "
+                                + "(with temporalFormat[" + temporalClass.getSimpleName() + "]="
+                                + jQuote(env.getTemporalFormat(temporalClass)) + ", "
+                                + "timeZone=" + jQuote(env.getTimeZone().toZoneId()) + ", "
+                                + "locale=" + jQuote(env.getLocale()) + ") "
+                                + "differs from expected.\n"
+                                + "Expected: " + expectedResult + "\n"
+                                + "Actual:   " + actualResult);
+            }
+        }
+    }
+
+}
diff --git a/src/test/java/freemarker/core/TemporalFormatTest2.java b/src/test/java/freemarker/core/TemporalFormatWithCustomFormatTest.java
similarity index 93%
rename from src/test/java/freemarker/core/TemporalFormatTest2.java
rename to src/test/java/freemarker/core/TemporalFormatWithCustomFormatTest.java
index 1329579..92b2a4a 100644
--- a/src/test/java/freemarker/core/TemporalFormatTest2.java
+++ b/src/test/java/freemarker/core/TemporalFormatWithCustomFormatTest.java
@@ -33,9 +33,9 @@ import freemarker.template.Configuration;
 import freemarker.test.TemplateTest;
 
 /**
- * Like {@link TemporalFormatTest}, but this one contains the tests that utilize {@link TemplateTest}.
+ * Like {@link TemporalFormatWithJavaFormatTest}, but this one contains the tests that utilize {@link TemplateTest}.
  */
-public class TemporalFormatTest2 extends TemplateTest {
+public class TemporalFormatWithCustomFormatTest extends TemplateTest {
 
     @Before
     public void setup() {
diff --git a/src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java b/src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java
new file mode 100644
index 0000000..f096970
--- /dev/null
+++ b/src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java
@@ -0,0 +1,313 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import static org.hamcrest.Matchers.*;
+import static org.junit.Assert.*;
+
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatterBuilder;
+import java.util.Locale;
+import java.util.function.Consumer;
+
+import org.junit.Test;
+
+import freemarker.template.TemplateException;
+
+public class TemporalFormatWithIsoFormatTest extends AbstractTemporalFormatTest {
+
+    private static final Consumer<Configurable> ISO_DATE_TIME_CONFIGURER = conf -> conf.setDateTimeFormat("iso");
+    private static final Consumer<Configurable> ISO_DATE_CONFIGURER = conf -> conf.setDateFormat("iso");
+    private static final Consumer<Configurable> ISO_TIME_CONFIGURER = conf -> conf.setTimeFormat("iso");
+
+    @Test
+    public void testFormatOffsetTime() throws TemplateException, IOException {
+        assertEquals(
+                "13:01:02Z",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 1, 2), ZoneOffset.UTC)));
+        assertEquals(
+                "13:01:02+01:00",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 1, 2), ZoneOffset.ofHours(1))));
+        assertEquals(
+                "13:00:00-02:30",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 0, 0), ZoneOffset.ofHoursMinutesSeconds(-2, -30, 0))));
+        assertEquals(
+                "13:00:00.0123Z",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 0, 0, 12_300_000), ZoneOffset.UTC)));
+        assertEquals(
+                "13:00:00.3Z",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 0, 0, 300_000_000), ZoneOffset.UTC)));
+    }
+
+    @Test
+    public void testFormatLocalTime() throws TemplateException, IOException {
+        assertEquals(
+                "13:01:02",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        LocalTime.of(13, 1, 2)));
+        assertEquals(
+                "13:00:00.0123",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        LocalTime.of(13, 0, 0, 12_300_000)));
+        assertEquals(
+                "13:00:00.3",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        LocalTime.of(13, 0, 0, 300_000_000)));
+    }
+
+    @Test
+    public void testFormatLocalDateTime() throws TemplateException, IOException {
+        assertEquals(
+                "2021-12-11T13:01:02",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        LocalDateTime.of(2021, 12, 11, 13, 1, 2, 0)));
+        assertEquals(
+                "2021-12-11T13:01:02.0123",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        LocalDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000)));
+        assertEquals(
+                "2021-12-11T13:01:02.3",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        LocalDateTime.of(2021, 12, 11, 13, 1, 2, 300_000_000)));
+    }
+
+    @Test
+    public void testFormatOffsetDateTime() throws TemplateException, IOException {
+        assertEquals(
+                "2021-12-11T13:01:02Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 0, ZoneOffset.UTC)));
+        assertEquals(
+                "2021-12-11T13:01:02+01:00",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 0, ZoneOffset.ofHours(1))));
+        assertEquals(
+                "2021-12-11T13:01:02-02:30",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 0, ZoneOffset.ofHoursMinutesSeconds(-2, -30, 0))));
+        assertEquals(
+                "2021-12-11T13:01:02.0123Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, ZoneOffset.UTC)));
+        assertEquals(
+                "2021-12-11T13:01:02.3Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 300_000_000, ZoneOffset.UTC)));
+    }
+
+    @Test
+    public void testFormatZonedDateTime() throws TemplateException, IOException {
+        assertEquals(
+                "2021-12-11T13:01:02Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        ZonedDateTime.of(2021, 12, 11, 13, 1, 2, 0, ZoneOffset.UTC)));
+        ZoneId zoneId = ZoneId.of("America/New_York");
+        assertEquals(
+                "2021-12-11T13:01:02-05:00",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        ZonedDateTime.of(2021, 12, 11, 13, 1, 2, 0, zoneId)));
+        assertEquals(
+                "2021-07-11T13:01:02-04:00",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        ZonedDateTime.of(2021, 7, 11, 13, 1, 2, 0, zoneId)));
+        assertEquals(
+                "2021-12-11T13:01:02-02:30",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 0, ZoneOffset.ofHoursMinutesSeconds(-2, -30, 0))));
+        assertEquals(
+                "2021-12-11T13:01:02.0123Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, ZoneOffset.UTC)));
+        assertEquals(
+                "2021-12-11T13:01:02.3Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 300_000_000, ZoneOffset.UTC)));
+    }
+
+    @Test
+    public void testFormatLocalDate() throws TemplateException, IOException {
+        assertEquals(
+                "2021-12-11",
+                formatTemporal(
+                        ISO_DATE_CONFIGURER,
+                        LocalDate.of(2021, 12, 11)));
+    }
+
+    @Test
+    public void testParseOffsetDateTime() throws TemplateException, TemplateValueFormatException {
+        // ISO extended and ISO basic format:
+        for (String s : new String[]{"2021-12-11T13:01:02.0123Z", "20211211T130102.0123Z"}) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURER,
+                    s,
+                    OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, ZoneOffset.UTC)) ;
+        }
+
+        // Optional parts:
+        for (String s : new String[] {
+                "2021-12-11T13:00:00.0+02:00",
+                "2021-12-11T13:00:00+02:00",
+                "2021-12-11T13:00+02",
+                "2021-12-11T13+02",
+                "20211211T130000.0+0200",
+                "20211211T130000+0200",
+                "20211211T1300+02",
+                "20211211T13+02",
+        }) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURER,
+                    s,
+                    OffsetDateTime.of(2021, 12, 11, 13, 0, 0, 0, ZoneOffset.ofHours(2)));
+        }
+
+        // TODO Zone default
+
+        try {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURER,
+                    "2021-12-11",
+                    OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, ZoneOffset.UTC));
+            fail("OffsetDateTime parsing should have failed");
+        } catch (UnparsableValueException e) {
+            assertThat(e.getMessage(), allOf(
+                    containsString("\"2021-12-11\""),
+                    containsString("OffsetDateTime"),
+                    containsString("\"T\"")
+            ));
+        }
+    }
+
+    @Test
+    public void testParseZonedDateTime() throws TemplateException, TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseLocalDateTime() throws TemplateException, TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseInstance() throws TemplateException, TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseLocalDate() throws TemplateException, TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseOffsetTime() throws TemplateException, TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseLocalTime() throws TemplateException, TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testHistoricalDates() throws TemplateException, TemplateValueFormatException {
+        for (boolean iso8601NegativeYear : new boolean[] {false, true}) {
+            LocalDate localDate = iso8601NegativeYear
+                    ? LocalDate.of(-100, 12, 11)
+                    : LocalDate.of(0, 12, 11);
+            String iso8601String = iso8601NegativeYear
+                    ? "-0100-12-11"
+                    : "0000-12-11";
+            // Just to show that ISO 8601 year 0 is 1 BC:
+            {
+                String stringWithYearOfEra = iso8601NegativeYear
+                        ? "101-12-11 BC"
+                        : "1-12-11 BC";
+                assertEquals(
+                        localDate,
+                        new DateTimeFormatterBuilder()
+                                .appendPattern("y-MM-dd G")
+                                .toFormatter(Locale.ROOT)
+                                .withZone(ZoneOffset.UTC)
+                                .parse(stringWithYearOfEra, LocalDate::from));
+            }
+
+            String output = formatTemporal(ISO_DATE_CONFIGURER, localDate);
+            assertEquals(iso8601String, output);
+            assertParsingResults(ISO_DATE_CONFIGURER, iso8601String, localDate);
+        }
+    }
+
+    @Test
+    public void testParseLocaleHasNoEffect() throws TemplateException, TemplateValueFormatException {
+        for (Locale locale : new Locale[] {
+                Locale.CHINA,
+                Locale.FRANCE,
+                new Locale("hi", "IN"),
+                new Locale.Builder()
+                        .setLocale(Locale.JAPAN)
+                        .setUnicodeLocaleKeyword("ca", "japanese")
+                        .build()}) {
+            LocalDateTime localDateTime = LocalDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000);
+            Consumer<Configurable> configurer = cfg -> {
+                cfg.setDateTimeFormat("iso");
+                cfg.setLocale(locale);
+            };
+            String output = formatTemporal(configurer, localDateTime);
+            String string = "2021-12-11T13:01:02.0123";
+            assertEquals(string, output);
+            assertParsingResults(configurer, string, localDateTime);
+        }
+    }
+
+}
diff --git a/src/test/java/freemarker/core/TemporalFormatTest.java b/src/test/java/freemarker/core/TemporalFormatWithJavaFormatTest.java
similarity index 60%
rename from src/test/java/freemarker/core/TemporalFormatTest.java
rename to src/test/java/freemarker/core/TemporalFormatWithJavaFormatTest.java
index ab6e479..0107341 100644
--- a/src/test/java/freemarker/core/TemporalFormatTest.java
+++ b/src/test/java/freemarker/core/TemporalFormatWithJavaFormatTest.java
@@ -19,13 +19,11 @@
 
 package freemarker.core;
 
-import static freemarker.template.utility.StringUtil.*;
 import static freemarker.test.hamcerst.Matchers.*;
 import static org.hamcrest.CoreMatchers.*;
 import static org.junit.Assert.*;
 
 import java.io.IOException;
-import java.io.UncheckedIOException;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
@@ -36,26 +34,20 @@ import java.time.YearMonth;
 import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
-import java.time.temporal.Temporal;
 import java.util.Locale;
 import java.util.TimeZone;
 import java.util.function.Consumer;
 
 import org.junit.Test;
 
-import freemarker.template.Configuration;
-import freemarker.template.SimpleTemporal;
-import freemarker.template.Template;
 import freemarker.template.TemplateException;
-import freemarker.template.TemplateTemporalModel;
-import freemarker.template.utility.ClassUtil;
 import freemarker.template.utility.DateUtil;
 import freemarker.test.hamcerst.Matchers;
 
-public class TemporalFormatTest {
+public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest {
 
     @Test
-    public void testOffsetTimeAndZones() throws TemplateException, IOException {
+    public void testFormatOffsetTimeAndZones() throws TemplateException, IOException {
         OffsetTime offsetTime = OffsetTime.of(LocalTime.of(10, 0, 0), ZoneOffset.ofHours(1));
 
         TimeZone timeZone = TimeZone.getTimeZone("America/New_York");
@@ -86,7 +78,7 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testZoneConvertedWhenOffsetOrZoneNotShown() throws TemplateException, IOException {
+    public void testFormatZoneConvertedWhenOffsetOrZoneNotShown() throws TemplateException, IOException {
         TimeZone gbZone = TimeZone.getTimeZone("GB");
         assertTrue(gbZone.useDaylightTime());
         // Summer: GMT+1
@@ -143,7 +135,7 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testCanNotFormatLocalIfTimeZoneIsShown() {
+    public void testFormatCanNotFormatLocalIfTimeZoneIsShown() {
         try {
             formatTemporal(
                     conf -> {
@@ -162,7 +154,7 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testStylesAreNotSupportedForYear() {
+    public void testFormatStylesAreNotSupportedForYear() {
         try {
             formatTemporal(
                     conf -> {
@@ -180,7 +172,7 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testStylesAreNotSupportedForYearMonth() {
+    public void testFormatStylesAreNotSupportedForYearMonth() {
         try {
             formatTemporal(
                     conf -> {
@@ -198,56 +190,129 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testDateTimeParsing() throws TemplateException, TemplateValueFormatException {
-        ZoneId zoneId = ZoneId.of("America/New_York");
-        TimeZone timeZone = TimeZone.getTimeZone(zoneId);
+    public void testParseDateTime() throws TemplateException, TemplateValueFormatException {
+        ZoneId cfgZoneId = ZoneId.of("America/New_York");
+        TimeZone cfgTimeZone = TimeZone.getTimeZone(cfgZoneId);
 
-        for (int i = 0; i < 2; i++) {
-            String stringToParse = i == 0 ? "2020-12-10 13:14" : "2020-07-10 13:14";
-            LocalDateTime localDateTime = i == 0
+        for (boolean winter : new boolean[] {true, false}) {
+            String stringToParse = winter ? "2020-12-10 13:14" : "2020-07-10 13:14";
+            LocalDateTime localDateTime = winter
                     ? LocalDateTime.of(2020, 12, 10, 13, 14)
                     : LocalDateTime.of(2020, 07, 10, 13, 14);
 
-            ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, zoneId);
-            assertParsingResults(
-                    conf -> {
-                        conf.setDateTimeFormat("y-MM-dd HH:mm");
-                        conf.setTimeZone(timeZone);
-                    },
-                    stringToParse, localDateTime,
-                    stringToParse, zonedDateTime.toOffsetDateTime(),
-                    stringToParse, zonedDateTime,
-                    stringToParse, zonedDateTime.toInstant());
+            {
+                ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, cfgZoneId);
+                assertParsingResults(
+                        conf -> {
+                            conf.setDateTimeFormat("y-MM-dd HH:mm");
+                            conf.setTimeZone(cfgTimeZone);
+                        },
+                        stringToParse, localDateTime,
+                        stringToParse, zonedDateTime,
+                        stringToParse, zonedDateTime.toInstant(),
+                        stringToParse, zonedDateTime.toOffsetDateTime());
+            }
+
+            {
+                String stringToParseWithOffset = stringToParse + "+02";
+                OffsetDateTime offsetDateTime = localDateTime.atOffset(ZoneOffset.ofHours(2));
+                ZonedDateTime zonedDateTime = offsetDateTime.toZonedDateTime();
+                assertParsingResults(
+                        conf -> {
+                            conf.setDateTimeFormat("y-MM-dd HH:mmX");
+                            conf.setTimeZone(cfgTimeZone);
+                        },
+                        stringToParseWithOffset, localDateTime,
+                        stringToParseWithOffset, zonedDateTime,
+                        stringToParseWithOffset, zonedDateTime.toInstant(),
+                        stringToParseWithOffset, offsetDateTime);
+            }
 
-            // TODO if zone is shown
+            {
+                ZoneId zoneIdToParse = ZoneId.of("Europe/Prague");
+                String stringToParseWithZone = stringToParse + " " + zoneIdToParse.getId();
+                ZonedDateTime zonedDateTime = localDateTime.atZone(zoneIdToParse);
+                assertParsingResults(
+                        conf -> {
+                            conf.setDateTimeFormat("y-MM-dd HH:mm z");
+                            conf.setTimeZone(cfgTimeZone);
+                        },
+                        stringToParseWithZone, localDateTime,
+                        stringToParseWithZone, zonedDateTime,
+                        stringToParseWithZone, zonedDateTime.toInstant(),
+                        stringToParseWithZone, zonedDateTime.toOffsetDateTime());
+            }
         }
     }
 
     @Test
-    public void testDateParsing() throws TemplateException, TemplateValueFormatException {
-        String stringToParse = "2020-11-10";
+    public void testParseWrongFormat() throws TemplateException, TemplateValueFormatException {
+        try {
+            assertParsingResults(
+                    conf -> conf.setDateTimeFormat("y-MM-dd HH:mm"),
+                    "2020-12-10 01:14 PM", LocalDateTime.of(2020, 12, 10, 13, 14));
+            fail("Parsing should have failed");
+        } catch (UnparsableValueException e) {
+            assertThat(
+                    e.getMessage(),
+                    allOf(
+                            containsString("\"2020-12-10 01:14 PM\""),
+                            containsString("\"y-MM-dd HH:mm\""),
+                            containsString("\"en_US\""),
+                            containsString("\"UTC\""),
+                            containsString("LocalDateTime")
+                    )
+            );
+        }
+    }
+
+    @Test
+    public void testParseDate() throws TemplateException, TemplateValueFormatException {
         LocalDate localDate = LocalDate.of(2020, 11, 10);
         assertParsingResults(
                 conf -> conf.setDateFormat("y-MM-dd"),
-                stringToParse, localDate);
+                "2020-11-10", localDate);
+        assertParsingResults(
+                conf -> conf.setDateFormat("yy-MM-dd"),
+                "20-11-10", localDate);
     }
 
     @Test
-    public void testLocalTimeParsing() throws TemplateException, TemplateValueFormatException {
+    public void testParseLocalTime() throws TemplateException, TemplateValueFormatException {
         String stringToParse = "13:14";
+
         assertParsingResults(
                 conf -> conf.setTimeFormat("HH:mm"),
                 stringToParse, LocalTime.of(13, 14));
-        // TODO if zone is shown
+
+        assertParsingResults(
+                conf -> {
+                    conf.setTimeFormat("HH:mmX");
+                    conf.setTimeZone(TimeZone.getTimeZone("GMT+02"));
+                },
+                stringToParse + "+02", LocalTime.of(13, 14));
     }
 
     @Test
-    public void testParsingLocalization() throws TemplateException, TemplateValueFormatException {
-        // TODO
+    public void testParseLocalization() throws TemplateException, TemplateValueFormatException {
+        LocalDate localDate = LocalDate.of(2020, 11, 10);
+        for (Locale locale : new Locale[] {
+                Locale.CHINA,
+                Locale.GERMANY,
+                new Locale("th", "TH"), // Because of the Buddhist calendar
+                Locale.US
+        }) {
+            Consumer<Configurable> configurer = conf -> {
+                conf.setDateFormat("y MMM dd");
+                conf.setLocale(locale);
+            };
+            String formattedDate = formatTemporal(configurer, localDate);
+            assertParsingResults(configurer, formattedDate, localDate);
+        }
     }
 
     @Test
-    public void testOffsetTimeParsing() throws TemplateException, TemplateValueFormatException {
+    public void testParseOffsetTime() throws TemplateException, TemplateValueFormatException {
         ZoneId zoneId = ZoneId.of("America/New_York");
         TimeZone timeZone = TimeZone.getTimeZone(zoneId);
 
@@ -273,92 +338,4 @@ public class TemporalFormatTest {
         }
     }
 
-    static private String formatTemporal(Consumer<Configurable> configurer, Temporal... values) throws
-            TemplateException {
-        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
-
-        configurer.accept(conf);
-
-        Environment env = null;
-        try {
-            env = new Template(null, "", conf).createProcessingEnvironment(null, null);
-        } catch (IOException e) {
-            throw new UncheckedIOException(e);
-        }
-
-        StringBuilder sb = new StringBuilder();
-        for (Temporal value : values) {
-            if (sb.length() != 0) {
-                sb.append(", ");
-            }
-            sb.append(env.formatTemporalToPlainText(new SimpleTemporal(value), null, false));
-        }
-
-        return sb.toString();
-    }
-
-    static private void assertParsingResults(
-            Consumer<Configurable> configurer,
-            Object... stringsAndExpectedResults) throws TemplateException, TemplateValueFormatException {
-        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
-        conf.setTimeZone(DateUtil.UTC);
-        conf.setLocale(Locale.US);
-
-        configurer.accept(conf);
-
-        Environment env = null;
-        try {
-            env = new Template(null, "", conf).createProcessingEnvironment(null, null);
-        } catch (IOException e) {
-            throw new UncheckedIOException(e);
-        }
-
-        if (stringsAndExpectedResults.length % 2 != 0) {
-            throw new IllegalArgumentException(
-                    "stringsAndExpectedResults.length must be even, but was " + stringsAndExpectedResults.length + ".");
-        }
-        for (int i = 0; i < stringsAndExpectedResults.length; i += 2) {
-            Object value = stringsAndExpectedResults[i];
-            if (!(value instanceof String)) {
-                throw new IllegalArgumentException("stringsAndExpectedResults[" + i + "] should be a String");
-            }
-            String string = (String) value;
-
-            value = stringsAndExpectedResults[i + 1];
-            if (!(value instanceof Temporal)) {
-                throw new IllegalArgumentException("stringsAndExpectedResults[" + (i + 1) + "] should be a Temporal");
-            }
-            Temporal expectedResult = (Temporal) value;
-
-            Class<? extends Temporal> temporalClass = expectedResult.getClass();
-            TemplateTemporalFormat templateTemporalFormat = env.getTemplateTemporalFormat(temporalClass);
-
-            Temporal actualResult;
-            {
-                Object actualResultObject = templateTemporalFormat.parse(string);
-                if (actualResultObject instanceof Temporal) {
-                    actualResult = (Temporal) actualResultObject;
-                } else if (actualResultObject instanceof TemplateTemporalModel) {
-                    actualResult = ((TemplateTemporalModel) actualResultObject).getAsTemporal();
-                } else {
-                    throw new AssertionError(
-                            "Parsing result of " + jQuote(string) + " is not of an expected type: "
-                                    + ClassUtil.getShortClassNameOfObject(actualResultObject));
-                }
-            }
-
-            if (!expectedResult.equals(actualResult)) {
-                throw new AssertionError(
-                        "Parsing result of " + jQuote(string) + " "
-                                + "(with temporalFormat[" + temporalClass.getSimpleName() + "]="
-                                + jQuote(env.getTemporalFormat(temporalClass)) + ", "
-                                + "timeZone=" + jQuote(env.getTimeZone().toZoneId()) + ", "
-                                + "locale=" + jQuote(env.getLocale()) + ") "
-                                + "differs from expected.\n"
-                                + "Expected: " + expectedResult + "\n"
-                                + "Actual:   " + actualResult);
-            }
-        }
-    }
-
 }
diff --git a/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java b/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
index 1f168e1..c11b6ef 100644
--- a/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
+++ b/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
@@ -90,23 +90,27 @@ public class DateUtilsPatternParsingTest {
 
     @Test
     public void testAllLettersAndWidths() {
-        for (String letter : new String[] {
-                "G", "y", "Y", "M", "L", "w", "W", "D", "d", "F", "E", "u", "a", "H", "k", "K", "h", "m", "s", "S",
-                "z", "Z", "X"}) {
-            for (int width = 1; width <= 6; width++) {
-                if (letter.equals("X") && width > 3) {
-                    // Not supported by SimpleDateFormat.
-                    continue;
-                }
-                String pattern = StringUtils.repeat(letter, width);
-                for (ZonedDateTime zdt : SAMPLE_ZDTS) {
-                    for (Locale locale : SAMPLE_LOCALES) {
-                        if (letter.equals("G") && _JavaVersion.FEATURE > 8 && !locale.equals(Locale.US)) {
-                            // SDF and DTF formats Era differently for many locales after Java 8. US locale remains
-                            // consistent as of Java 13, so let's hope it won't break, and so we can have some coverage.
-                            continue;
+        // Prefix is used to have both standalone and non-standalone formatting of the repeated letter.
+        for (String prefix : new String[] {"", "y "}) {
+            for (String letter : new String[]{
+                    "G", "y", "Y", "M", "L", "w", "W", "D", "d", "F", "E", "u", "a", "H", "k", "K", "h", "m", "s", "S",
+                    "z", "Z", "X"}) {
+                for (int width = 1; width <= 6; width++) {
+                    if (letter.equals("X") && width > 3) {
+                        // Not supported by SimpleDateFormat.
+                        continue;
+                    }
+                    String pattern = prefix + StringUtils.repeat(letter, width);
+                    for (ZonedDateTime zdt : SAMPLE_ZDTS) {
+                        for (Locale locale : SAMPLE_LOCALES) {
+                            if (letter.equals("G") && _JavaVersion.FEATURE > 8 && !locale.equals(Locale.US)) {
+                                // SDF and DTF formats Era differently for many locales after Java 8. US locale remains
+                                // consistent as of Java 13, so let's hope it won't break, and so we can have some
+                                // coverage.
+                                continue;
+                            }
+                            assertSDFAndDTFOutputsEqual(pattern, zdt, locale);
                         }
-                        assertSDFAndDTFOutputsEqual(pattern, zdt, locale);
                     }
                 }
             }
@@ -136,17 +140,6 @@ public class DateUtilsPatternParsingTest {
         }
     }
 
-
-    @Test
-    public void testStandaloneOrNot() {
-        for (Locale locale : SAMPLE_LOCALES) {
-            assertSDFAndDTFOutputsEqual("MMM", SAMPLE_ZDT, locale);
-            assertSDFAndDTFOutputsEqual("y MMM", SAMPLE_ZDT, locale);
-            assertSDFAndDTFOutputsEqual("MMMM", SAMPLE_ZDT, locale);
-            assertSDFAndDTFOutputsEqual("y MMMM", SAMPLE_ZDT, locale);
-        }
-    }
-
     @Test
     public void testCalendars() {
         Locale baseLocale = new Locale("th", "TH");
@@ -183,14 +176,14 @@ public class DateUtilsPatternParsingTest {
     @Test
     public void testInvalidPatternExceptions() {
         try {
-            DateUtil.dateTimeFormatterFromSimpleDateFormatPattern("y v", SAMPLE_LOCALE);
+            TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("y v", SAMPLE_LOCALE);
             fail();
         } catch (IllegalArgumentException e) {
             assertThat(e.getMessage(), Matchers.containsString("\"v\""));
         }
 
         try {
-            DateUtil.dateTimeFormatterFromSimpleDateFormatPattern("XXXX", SAMPLE_LOCALE);
+            TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("XXXX", SAMPLE_LOCALE);
             fail();
         } catch (IllegalArgumentException e) {
             assertThat(e.getMessage(), Matchers.containsString("4"));
@@ -202,7 +195,7 @@ public class DateUtilsPatternParsingTest {
         assertEquals(
                 LocalDateTime.of(2021, 12, 23, 1, 2, 3),
                 LocalDateTime.from(
-                        DateUtil.dateTimeFormatterFromSimpleDateFormatPattern("yyyyMMddHHmmss", SAMPLE_LOCALE)
+                        TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("yyyyMMddHHmmss", SAMPLE_LOCALE)
                                 .parse("20211223010203")));
     }
 
@@ -270,7 +263,7 @@ public class DateUtilsPatternParsingTest {
         SimpleDateFormat sdf = new SimpleDateFormat(pattern, locale);
         sdf.setTimeZone(timeZone);
 
-        DateTimeFormatter dtf = DateUtil.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale);
+        DateTimeFormatter dtf = TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale);
 
         String sdfOutput = sdf.format(date);
         String dtfOutput = dtf.format(temporal);
@@ -309,7 +302,7 @@ public class DateUtilsPatternParsingTest {
 
     private LocalDate parseLocalDate(String pattern, String string, Locale locale) {
         return LocalDate.from(
-                DateUtil.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale)
+                TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale)
                         .parse(string));
     }
 
diff --git a/src/test/java/freemarker/core/CoreTemporalUtilTest.java b/src/test/java/freemarker/template/utility/TemporalUtilsTest.java
similarity index 66%
rename from src/test/java/freemarker/core/CoreTemporalUtilTest.java
rename to src/test/java/freemarker/template/utility/TemporalUtilsTest.java
index 36fd589..2b5c06f 100644
--- a/src/test/java/freemarker/core/CoreTemporalUtilTest.java
+++ b/src/test/java/freemarker/template/utility/TemporalUtilsTest.java
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-package freemarker.core;
+package freemarker.template.utility;
 
 import static org.hamcrest.Matchers.*;
 import static org.junit.Assert.*;
@@ -31,21 +31,21 @@ import org.junit.Test;
 
 import freemarker.template.Configuration;
 
-public class CoreTemporalUtilTest {
+public class TemporalUtilsTest {
 
     @Test
     public void testSupportedTemporalClassAreFinal() {
         assertTrue(
                 "FreeMarker was implemented with the assumption that temporal classes are final. While there "
-                        + "are mesures in palce to handle if it's not a case, it would be better to review the code.",
-                _CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL);
+                        + "are measures in place to handle if it's not a case, it would be better to review the code.",
+                TemporalUtils.SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL);
     }
 
     @Test
     public void testGetTemporalFormat() {
         Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
 
-        for (Class<? extends Temporal> supportedTemporalClass : _CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
+        for (Class<? extends Temporal> supportedTemporalClass : TemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
             assertNotNull(cfg.getTemporalFormat(supportedTemporalClass));
         }
 
@@ -61,15 +61,18 @@ public class CoreTemporalUtilTest {
     public void testTemporalClassToFormatSettingName() {
         Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
 
-        Set<String> uniqueSettingNames = new HashSet<>();
-        for (Class<? extends Temporal> supportedTemporalClass : _CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
-            uniqueSettingNames.add(_CoreTemporalUtils.temporalClassToFormatSettingName(supportedTemporalClass));
+        for (boolean camelCase : new boolean[] {false, true}) {
+            Set<String> uniqueSettingNames = new HashSet<>();
+            for (Class<? extends Temporal> supportedTemporalClass : TemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
+                uniqueSettingNames.add(
+                        TemporalUtils.temporalClassToFormatSettingName(supportedTemporalClass, camelCase));
+            }
+            assertThat(uniqueSettingNames.size(), equalTo(TemporalUtils.SUPPORTED_TEMPORAL_CLASSES.size() - 4));
+            assertTrue(uniqueSettingNames.stream().allMatch(it -> cfg.getSettingNames(camelCase).contains(it)));
         }
-        assertThat(uniqueSettingNames.size(), equalTo(_CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES.size() - 4));
-        assertTrue(uniqueSettingNames.stream().allMatch(it -> cfg.getSettingNames(false).contains(it)));
 
         try {
-            _CoreTemporalUtils.temporalClassToFormatSettingName(ChronoLocalDate.class);
+            TemporalUtils.temporalClassToFormatSettingName(ChronoLocalDate.class, false);
             fail();
         } catch (IllegalArgumentException e) {
             // Expected