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 2020/06/08 00:38:36 UTC

[freemarker] 02/02: FREEMARKER-35: Reworked things so that it will allow format caching and custom formatters later. Basically TemporalUtils was sliced up to TemplateTemporalFormat-s and their factories, and to some Environment code. Fixed many rough edges along the way, and discovered even more (added TODO comments for them).

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 7f6e528006c34a82f1b79481209cb8ea2439d101
Author: ddekany <dd...@apache.org>
AuthorDate: Sun Jun 7 21:23:18 2020 +0200

    FREEMARKER-35: Reworked things so that it will allow format caching and custom formatters later. Basically TemporalUtils was sliced up to TemplateTemporalFormat-s and their factories, and to some Environment code. Fixed many rough edges along the way, and discovered even more (added TODO comments for them).
---
 ...seJavaTemplateTemporalFormatTemplateFormat.java |  64 ++++++
 .../freemarker/core/BuiltInsForMultipleTypes.java  |  11 +-
 src/main/java/freemarker/core/Configurable.java    |  16 +-
 src/main/java/freemarker/core/Environment.java     | 254 ++++++++++++++++-----
 src/main/java/freemarker/core/EvalUtil.java        |  12 +-
 .../core/ISOLikeTemplateTemporalFormat.java        |  47 ++++
 .../core/ISOTemplateTemporalFormatFactory.java     | 118 ++++++++++
 .../core/JavaTemplateTemporalFormat.java           | 129 +++++++++++
 .../core/JavaTemplateTemporalFormatFactory.java    |  42 ++++
 .../freemarker/core/TemplateTemporalFormat.java    |  44 ++--
 .../core/TemplateTemporalFormatFactory.java        |  81 +++++++
 ...at.java => ToStringTemplateTemporalFormat.java} |  46 ++--
 .../ToStringTemplateTemporalFormatFactory.java     |  42 ++++
 .../core/UnformattableTemporalTypeException.java   |  38 +++
 .../core/XSTemplateTemporalFormatFactory.java      | 124 ++++++++++
 .../java/freemarker/core/_CoreTemporalUtils.java   |  40 +++-
 src/main/java/freemarker/core/_MessageUtil.java    |   4 +-
 .../freemarker/template/utility/TemporalUtil.java  | 197 ----------------
 ...igurableTest.java => CoreTemporalUtilTest.java} |  26 ++-
 .../freemarker/core/TemporalErrorMessagesTest.java |  55 +++++
 .../test/templatesuite/TemplateTestCase.java       |   2 +
 .../test/templatesuite/templates/temporal.ftl      |   4 +
 22 files changed, 1070 insertions(+), 326 deletions(-)

diff --git a/src/main/java/freemarker/core/BaseJavaTemplateTemporalFormatTemplateFormat.java b/src/main/java/freemarker/core/BaseJavaTemplateTemporalFormatTemplateFormat.java
new file mode 100644
index 0000000..a7e7412
--- /dev/null
+++ b/src/main/java/freemarker/core/BaseJavaTemplateTemporalFormatTemplateFormat.java
@@ -0,0 +1,64 @@
+/*
+ * 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.DateTimeException;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.Temporal;
+
+import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateTemporalModel;
+
+abstract class BaseJavaTemplateTemporalFormatTemplateFormat extends TemplateTemporalFormat {
+    private final DateTimeFormatter dateTimeFormatterWithZoneOverride;
+
+    protected BaseJavaTemplateTemporalFormatTemplateFormat(DateTimeFormatter dateTimeFormatterWithZoneOverride) {
+        this.dateTimeFormatterWithZoneOverride = dateTimeFormatterWithZoneOverride;
+    }
+
+    @Override
+    public String format(TemplateTemporalModel tm)
+            throws TemplateValueFormatException, TemplateModelException {
+        try {
+            DateTimeFormatter dateTimeFormatter = this.dateTimeFormatterWithZoneOverride;
+            Temporal temporal = TemplateFormatUtil.getNonNullTemporal(tm);
+
+            // TODO [FREEMARKER-35] Doing these on runtime is wasteful if it's know if for which format setting
+            // this object is used for.
+            if (temporal instanceof Instant) {
+                temporal = ((Instant) temporal).atZone(dateTimeFormatter.getZone());
+            } else if (temporal instanceof OffsetDateTime) {
+                dateTimeFormatter = dateTimeFormatter.withZone(((OffsetDateTime) temporal).getOffset());
+            } else if (temporal instanceof OffsetTime) {
+                dateTimeFormatter = dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset());
+            } else if (temporal instanceof ZonedDateTime) {
+                dateTimeFormatter = dateTimeFormatter.withZone(null);
+            }
+
+            return dateTimeFormatter.format(temporal);
+        } catch (DateTimeException e) {
+            throw new UnformattableValueException(e.getMessage(), e);
+        }
+    }
+}
diff --git a/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java b/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java
index 1159d5d..8cc6942 100644
--- a/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java
+++ b/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java
@@ -606,7 +606,6 @@ class BuiltInsForMultipleTypes {
             }
         }
     
-
         private class TemporalFormatter implements TemplateScalarModel, TemplateHashModel, TemplateMethodModel {
             private final TemplateTemporalModel temporalModel;
             private final Environment env;
@@ -616,7 +615,7 @@ class BuiltInsForMultipleTypes {
             TemporalFormatter(TemplateTemporalModel temporalModel, Environment env) throws TemplateException {
                 this.temporalModel = temporalModel;
                 this.env = env;
-                this.defaultFormat = env.getTemplateTemporalFormat(temporalModel.getAsTemporal().getClass());
+                this.defaultFormat = env.getTemplateTemporalFormat(temporalModel, target, false);
             }
 
             @Override
@@ -633,12 +632,10 @@ class BuiltInsForMultipleTypes {
             private TemplateModel formatWith(String key)
                     throws TemplateModelException {
                 try {
-                    return new SimpleScalar(env.formatTemporalToPlainText(temporalModel, key, target));
+                    return new SimpleScalar(env.formatTemporalToPlainText(temporalModel, key, target, stringBI.this, true));
                 } catch (TemplateException e) {
                     // `e` should always be a TemplateModelException here, but to be sure:
                     throw _CoreAPI.ensureIsTemplateModelException("Failed to format value", e);
-                } catch (TemplateValueFormatException e) {
-                    throw new _TemplateModelException("Failed to format value", e);
                 }
             }
 
@@ -652,7 +649,7 @@ class BuiltInsForMultipleTypes {
                         cachedValue = EvalUtil.assertFormatResultNotNull(defaultFormat.format(temporalModel));
                     } catch (TemplateValueFormatException e) {
                         try {
-                            throw _MessageUtil.newCantFormatDateException(defaultFormat, target, e, true);
+                            throw _MessageUtil.newCantFormatTemporalException(defaultFormat, target, e, true);
                         } catch (TemplateException e2) {
                             // `e` should always be a TemplateModelException here, but to be sure:
                             throw _CoreAPI.ensureIsTemplateModelException("Failed to format date/time/datetime", e2);
@@ -688,7 +685,7 @@ class BuiltInsForMultipleTypes {
                 this.defaultFormat = dateType == TemplateDateModel.UNKNOWN
                         ? null  // Lazy unknown type error in getAsString()
                         : env.getTemplateDateFormat(
-                                dateType, EvalUtil.modelToDate(dateModel, target).getClass(), target, true);
+                                dateType, EvalUtil.modelToDate(dateModel, target).getClass(), target, false);
             }
     
             @Override
diff --git a/src/main/java/freemarker/core/Configurable.java b/src/main/java/freemarker/core/Configurable.java
index c891510..d3c8646 100644
--- a/src/main/java/freemarker/core/Configurable.java
+++ b/src/main/java/freemarker/core/Configurable.java
@@ -46,6 +46,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Objects;
 import java.util.Properties;
 import java.util.Set;
 import java.util.TimeZone;
@@ -1414,11 +1415,12 @@ public class Configurable {
      * @return Never {@code null}, maybe {@code ""} though.
      *
      * @throws NullPointerException If {@link temporalClass} was {@code null}
-     * @throws IllegalArgumentException If {@link temporalClass} was not a supported {@link Temporal} subclass.
+     * @throws IllegalArgumentException If {@link temporalClass} is not a supported {@link Temporal} subclass.
      *
      * @since 2.3.31
      */
     public String getTemporalFormat(Class<? extends Temporal> temporalClass) {
+        Objects.requireNonNull(temporalClass);
         if (temporalClass == Instant.class) {
             return getInstantFormat();
         } else if (temporalClass == LocalDate.class) {
@@ -1524,7 +1526,17 @@ public class Configurable {
         }
         return parent != null ? parent.getCustomDateFormat(name) : null;
     }
-    
+
+    /**
+     * Gets the custom name format registered for the name.
+     *
+     * @since 2.3.31
+     */
+    public TemplateTemporalFormatFactory getCustomTemporalFormat(String name) {
+        // TODO [FREEMARKER-35]
+        return null;
+    }
+
     /**
      * Sets the exception handler used to handle exceptions occurring inside templates.
      * The default is {@link TemplateExceptionHandler#DEBUG_HANDLER}. The recommended values are:
diff --git a/src/main/java/freemarker/core/Environment.java b/src/main/java/freemarker/core/Environment.java
index 3a0a251..0e57db4 100644
--- a/src/main/java/freemarker/core/Environment.java
+++ b/src/main/java/freemarker/core/Environment.java
@@ -1637,13 +1637,7 @@ public final class Environment extends Configurable {
             final String name;
             final String params;
             {
-                int endIdx;
-                findParamsStart: for (endIdx = 1; endIdx < formatStringLen; endIdx++) {
-                    char c = formatString.charAt(endIdx);
-                    if (c == ' ' || c == '_') {
-                        break findParamsStart;
-                    }
-                }
+                int endIdx = getCustomFormatStringNameEnd(formatString, formatStringLen);
                 name = formatString.substring(1, endIdx);
                 params = endIdx < formatStringLen ? formatString.substring(endIdx + 1) : "";
             }
@@ -1729,62 +1723,37 @@ public final class Environment extends Configurable {
     }
 
     /**
-     * @param tdmSourceExpr
+     * @param blamedTdmSourceExpr
      *            The blamed expression if an error occurs; only used for error messages.
      */
-    String formatDateToPlainText(TemplateDateModel tdm, Expression tdmSourceExpr,
+    String formatDateToPlainText(TemplateDateModel tdm, Expression blamedTdmSourceExpr,
             boolean useTempModelExc) throws TemplateException {
-        TemplateDateFormat format = getTemplateDateFormat(tdm, tdmSourceExpr, useTempModelExc);
-        
+        TemplateDateFormat format = getTemplateDateFormat(tdm, blamedTdmSourceExpr, useTempModelExc);
         try {
             return EvalUtil.assertFormatResultNotNull(format.formatToPlainText(tdm));
         } catch (TemplateValueFormatException e) {
-            throw _MessageUtil.newCantFormatDateException(format, tdmSourceExpr, e, useTempModelExc);
+            throw _MessageUtil.newCantFormatDateException(format, blamedTdmSourceExpr, e, useTempModelExc);
         }
     }
 
     /**
-     * @param blamedDateSourceExp
+     * @param blamedTdmSourceExp
      *            The blamed expression if an error occurs; only used for error messages.
      * @param blamedFormatterExp
      *            The blamed expression if an error occurs; only used for error messages.
      */
     String formatDateToPlainText(TemplateDateModel tdm, String formatString,
-            Expression blamedDateSourceExp, Expression blamedFormatterExp,
+            Expression blamedTdmSourceExp, Expression blamedFormatterExp,
             boolean useTempModelExc) throws TemplateException {
-        Date date = EvalUtil.modelToDate(tdm, blamedDateSourceExp);
-        
         TemplateDateFormat format = getTemplateDateFormat(
-                formatString, tdm.getDateType(), date.getClass(),
-                blamedDateSourceExp, blamedFormatterExp,
+                formatString, tdm.getDateType(), EvalUtil.modelToDate(tdm, blamedTdmSourceExp).getClass(),
+                blamedTdmSourceExp, blamedFormatterExp,
                 useTempModelExc);
         
         try {
             return EvalUtil.assertFormatResultNotNull(format.formatToPlainText(tdm));
         } catch (TemplateValueFormatException e) {
-            throw _MessageUtil.newCantFormatDateException(format, blamedDateSourceExp, e, useTempModelExc);
-        }
-    }
-
-    /**
-     * @param blamedDateSourceExp
-     *            The blamed expression if an error occurs; only used for error messages.
-     */
-    String formatTemporalToPlainText(TemplateTemporalModel ttm, String formatString, Expression blamedDateSourceExp) throws TemplateException, TemplateValueFormatException {
-        TemplateTemporalFormat ttf = getTemplateTemporalFormat(ttm.getAsTemporal().getClass(), formatString, true);
-        try {
-            return EvalUtil.assertFormatResultNotNull(ttf.format(ttm));
-        } catch (TemplateValueFormatException e) {
-            throw _MessageUtil.newCantFormatDateException(ttf, blamedDateSourceExp, e, true);
-        }
-    }
-
-    String formatTemporalToPlainText(TemplateTemporalModel ttm, Expression tdmSourceExpr) throws TemplateException {
-        TemplateTemporalFormat ttf = getTemplateTemporalFormat(ttm.getAsTemporal().getClass());
-        try {
-            return EvalUtil.assertFormatResultNotNull(ttf.format(ttm));
-        } catch (TemplateValueFormatException e) {
-            throw _MessageUtil.newCantFormatDateException(ttf, tdmSourceExpr, e, false);
+            throw _MessageUtil.newCantFormatDateException(format, blamedTdmSourceExp, e, useTempModelExc);
         }
     }
 
@@ -1963,7 +1932,8 @@ public final class Environment extends Configurable {
     }
 
     /**
-     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to {@link TemplateException}-s.
+     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to moer informative
+     * {@link TemplateException}-s.
      */
     TemplateDateFormat getTemplateDateFormat(
             int dateType, Class<? extends Date> dateClass, Expression blamedDateSourceExp, boolean useTempModelExc)
@@ -2003,7 +1973,7 @@ public final class Environment extends Configurable {
     }
 
     /**
-     * Same as {@link #getTemplateDateFormat(String, int, Class)}, but translates the exceptions to
+     * Same as {@link #getTemplateDateFormat(String, int, Class)}, but translates the exceptions to more informative
      * {@link TemplateException}-s.
      */
     TemplateDateFormat getTemplateDateFormat(
@@ -2164,13 +2134,7 @@ public final class Environment extends Configurable {
                 && Character.isLetter(formatString.charAt(1))) {
             final String name;
             {
-                int endIdx;
-                findParamsStart: for (endIdx = 1; endIdx < formatStringLen; endIdx++) {
-                    char c = formatString.charAt(endIdx);
-                    if (c == ' ' || c == '_') {
-                        break findParamsStart;
-                    }
-                }
+                int endIdx = getCustomFormatStringNameEnd(formatString, formatStringLen);
                 name = formatString.substring(1, endIdx);
                 formatParams = endIdx < formatStringLen ? formatString.substring(endIdx + 1) : "";
             }
@@ -2219,14 +2183,192 @@ public final class Environment extends Configurable {
                 + (sqlDTTZ ? CACHED_TDFS_SQL_D_T_TZ_OFFS : 0);
     }
 
-    TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> temporalClass) {
-        // TODO [FREEMARKER-35] Temporal class keyed cache, invalidated by temporalFormat (instantFormat, localDateFormat, etc.), locale and timeZone change.
-        return getTemplateTemporalFormat(temporalClass, getTemporalFormat(temporalClass), true);
+    /**
+     * @param blamedTtmSourceExp
+     *            The blamed expression if an error occurs; only used for error messages.
+     */
+    String formatTemporalToPlainText(TemplateTemporalModel ttm, String formatString,
+            Expression blamedTtmSourceExp, Expression blamedFormatterSourceExp,
+            boolean useTempModelExc)
+            throws TemplateException {
+        TemplateTemporalFormat ttf = getTemplateTemporalFormat(
+                formatString, ttm,
+                blamedTtmSourceExp, blamedFormatterSourceExp,
+                useTempModelExc);
+        try {
+            return EvalUtil.assertFormatResultNotNull(ttf.format(ttm));
+        } catch (TemplateValueFormatException e) {
+            throw _MessageUtil.newCantFormatTemporalException(ttf, blamedTtmSourceExp, e, true);
+        }
+    }
+
+    String formatTemporalToPlainText(TemplateTemporalModel ttm, Expression blamedTtmSourceExp,
+            boolean useTempModelExc) throws TemplateException {
+        TemplateTemporalFormat ttf = getTemplateTemporalFormat(
+                ttm, blamedTtmSourceExp, useTempModelExc);
+        try {
+            return EvalUtil.assertFormatResultNotNull(ttf.format(ttm));
+        } catch (TemplateValueFormatException e) {
+            throw _MessageUtil.newCantFormatTemporalException(ttf, blamedTtmSourceExp, e, false);
+        }
+    }
+
+    /**
+     * Convenience overload of {@link #getTemplateTemporalFormat(Class, Expression, boolean)}.
+     */
+    TemplateTemporalFormat getTemplateTemporalFormat(
+            TemplateTemporalModel ttm, Expression blamedTemporalSourceExp, boolean useTempModelExc)
+            throws TemplateException {
+        return getTemplateTemporalFormat(
+                EvalUtil.modelToTemporal(
+                        ttm, blamedTemporalSourceExp).getClass(), blamedTemporalSourceExp, useTempModelExc);
+    }
+
+    /**
+     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to moer informative
+     * {@link TemplateException}-s.
+     */
+    TemplateTemporalFormat getTemplateTemporalFormat(
+            Class<? extends Temporal> temporalClass, Expression blamedTemporalSourceExp, boolean useTempModelExc)
+            throws TemplateException {
+        try {
+            return getTemplateTemporalFormat(temporalClass);
+        } catch (TemplateValueFormatException e) {
+            String settingName;
+            String settingValue;
+            try {
+                settingName = _CoreTemporalUtils.temporalClassToFormatSettingName(temporalClass);
+                settingValue = getTemporalFormat(temporalClass);
+            } catch (IllegalArgumentException e2) {
+                settingName = "???";
+                settingValue = "???";
+            }
+
+            _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder(
+                    "The value of the \"", settingName,
+                    "\" FreeMarker configuration setting is a malformed temporal format string: ",
+                    new _DelayedJQuote(settingValue), ". Reason given: ",
+                    e.getMessage());
+            throw useTempModelExc ? new _TemplateModelException(e, desc) : new _MiscTemplateException(e, desc);
+        }
     }
 
-    private TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> temporalClass, String format, boolean cache) {
-        // TODO [FREEMARKER-35] format keyed cache, invalidated by local and timeZone change.
-        return new TemplateTemporalFormat(format, getLocale(), getTimeZone());
+    TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> temporalClass)
+            throws TemplateValueFormatException {
+        // TODO [FREEMARKER-35] Temporal class keyed cache, invalidated by temporalFormat (instantFormat, localDateFormat, etc.), locale, and timeZone change.
+        return getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+    }
+
+    /**
+     * Convenience overload of {@link #getTemplateTemporalFormat(String, Class, Expression, Expression, boolean)}.
+     */
+    TemplateTemporalFormat getTemplateTemporalFormat(
+            String formatString, TemplateTemporalModel ttm,
+            Expression blamedTemporalSourceExp, Expression blamedFormatterExp,
+            boolean useTempModelExc)
+            throws TemplateException {
+        return getTemplateTemporalFormat(
+                formatString, EvalUtil.modelToTemporal(ttm, blamedFormatterExp).getClass(),
+                blamedTemporalSourceExp, blamedFormatterExp,
+                useTempModelExc);
+    }
+
+    /**
+     * Same as {@link #getTemplateTemporalFormat(String, Class)}, but translates the exceptions to more informative
+     * {@link TemplateException}-s.
+     */
+    TemplateTemporalFormat getTemplateTemporalFormat(
+            String formatString, Class<? extends Temporal> temporalClass,
+            Expression blamedTemporalSourceExp, Expression blamedFormatterExp,
+            boolean useTempModelExc)
+            throws TemplateException {
+        try {
+            return getTemplateTemporalFormat(formatString, temporalClass);
+        } catch (TemplateValueFormatException e) {
+            _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder(
+                    "Can't create temporal format based on format string ",
+                    new _DelayedJQuote(formatString), ". Reason given: ",
+                    e.getMessage())
+                    .blame(blamedFormatterExp);
+            throw useTempModelExc ? new _TemplateModelException(e, desc) : new _MiscTemplateException(e, desc);
+        }
+    }
+
+    private TemplateTemporalFormat getTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass)
+            throws TemplateValueFormatException {
+        // TODO [FREEMARKER-35] format keyed cache, invalidated by locale, and timeZone change.
+        return getTemplateTemporalFormatWithoutCache(formatString, temporalClass, getLocale(), getTimeZone());
+    }
+
+    /**
+     * Returns the {@link TemplateTemporalFormat} for the given parameters without using the {@link Environment}-level
+     * cache. Of course, the {@link TemplateTemporalFormatFactory} involved might still uses its own cache, which can be
+     * global (class-loader-level) or {@link Environment}-level.
+     *
+     * @param formatString
+     *            See the similar parameter of {@link TemplateTemporalFormatFactory#get}
+     * @param dateType
+     *            See the similar parameter of {@link TemplateTemporalFormatFactory#get}
+     * @param zonelessInput
+     *            See the similar parameter of {@link TemplateTemporalFormatFactory#get}
+     */
+    private TemplateTemporalFormat getTemplateTemporalFormatWithoutCache(
+            String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone)
+            throws TemplateValueFormatException {
+        final int formatStringLen = formatString.length();
+        final String formatParams;
+
+        TemplateTemporalFormatFactory formatFactory;
+        char firstChar = formatStringLen != 0 ? formatString.charAt(0) : 0;
+
+        if (firstChar == 'x'
+                && formatStringLen > 1
+                && formatString.charAt(1) == 's') {
+            formatFactory = XSTemplateTemporalFormatFactory.INSTANCE;
+            formatParams = formatString.substring(2);
+        } else if (firstChar == 'i'
+                && formatStringLen > 2
+                && formatString.charAt(1) == 's'
+                && formatString.charAt(2) == 'o') {
+            formatFactory = ISOTemplateTemporalFormatFactory.INSTANCE;
+            formatParams = formatString.substring(3);
+        } else if (firstChar == '@'
+                && formatStringLen > 1
+                && Character.isLetter(formatString.charAt(1))) {
+            final String name;
+            {
+                int endIdx = getCustomFormatStringNameEnd(formatString, formatStringLen);
+                name = formatString.substring(1, endIdx);
+                formatParams = endIdx < formatStringLen ? formatString.substring(endIdx + 1) : "";
+            }
+
+            formatFactory = getCustomTemporalFormat(name);
+            if (formatFactory == null) {
+                throw new UndefinedCustomFormatException(
+                        "No custom temporal format was defined with name " + StringUtil.jQuote(name));
+            }
+        } else if (formatStringLen == 0) {
+            // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did
+            formatParams = formatString;
+            formatFactory = ToStringTemplateTemporalFormatFactory.INSTANCE;
+        } else {
+            formatParams = formatString;
+            formatFactory = JavaTemplateTemporalFormatFactory.INSTANCE;
+        }
+
+        return formatFactory.get(formatParams, temporalClass, locale, timeZone, this);
+    }
+
+    private static int getCustomFormatStringNameEnd(String formatString, int formatStringLen) {
+        int endIdx;
+        findParamsStart:
+        for (endIdx = 1; endIdx < formatStringLen; endIdx++) {
+            char c = formatString.charAt(endIdx);
+            if (c == ' ' || c == '_') {
+                break findParamsStart;
+            }
+        }
+        return endIdx;
     }
 
     /**
diff --git a/src/main/java/freemarker/core/EvalUtil.java b/src/main/java/freemarker/core/EvalUtil.java
index 50f32c5..78d626a 100644
--- a/src/main/java/freemarker/core/EvalUtil.java
+++ b/src/main/java/freemarker/core/EvalUtil.java
@@ -409,11 +409,11 @@ class EvalUtil {
             }
         } else if (tm instanceof TemplateTemporalModel) {
             TemplateTemporalModel ttm = (TemplateTemporalModel) tm;
-            TemplateTemporalFormat format = env.getTemplateTemporalFormat(ttm.getAsTemporal().getClass());
+            TemplateTemporalFormat format = env.getTemplateTemporalFormat(ttm.getAsTemporal().getClass(), exp, false);
             try {
                 return assertFormatResultNotNull(format.format(ttm));
             } catch (TemplateValueFormatException e) {
-                throw _MessageUtil.newCantFormatDateException(format, exp, e, false);
+                throw _MessageUtil.newCantFormatTemporalException(format, exp, e, false);
             }
         } else if (tm instanceof TemplateMarkupOutputModel) {
             return tm;
@@ -424,7 +424,7 @@ class EvalUtil {
 
     /**
      * Like {@link #coerceModelToStringOrMarkup(TemplateModel, Expression, String, Environment)}, but gives error
-     * if the result is markup. This is what you normally use where markup results can't be used.
+     * if the result is markup. This is what you normally used where markup results can't be used.
      *
      * @param seqTip
      *            Tip to display if the value type is not coercable, but it's sequence or collection.
@@ -452,11 +452,11 @@ class EvalUtil {
             }
         } else if (tm instanceof TemplateTemporalModel) {
             TemplateTemporalModel ttm = (TemplateTemporalModel) tm;
-            TemplateTemporalFormat format = env.getTemplateTemporalFormat(ttm.getAsTemporal().getClass());
+            TemplateTemporalFormat format = env.getTemplateTemporalFormat(ttm, exp, false);
             try {
                 return ensureFormatResultString(format.format(ttm), exp, env);
             } catch (TemplateValueFormatException e) {
-                throw _MessageUtil.newCantFormatDateException(format, exp, e, false);
+                throw _MessageUtil.newCantFormatTemporalException(format, exp, e, false);
             }
         } else {
             return coerceModelToTextualCommon(tm, exp, seqTip, false, false, env);
@@ -480,7 +480,7 @@ class EvalUtil {
         } else if (tm instanceof TemplateDateModel) {
             return assertFormatResultNotNull(env.formatDateToPlainText((TemplateDateModel) tm, exp, false));
         } else if (tm instanceof TemplateTemporalModel) {
-            return assertFormatResultNotNull(env.formatTemporalToPlainText((TemplateTemporalModel) tm, exp));
+            return assertFormatResultNotNull(env.formatTemporalToPlainText((TemplateTemporalModel) tm, exp, false));
         } else {
             return coerceModelToTextualCommon(tm, exp, seqTip, false, false, env);
         }
diff --git a/src/main/java/freemarker/core/ISOLikeTemplateTemporalFormat.java b/src/main/java/freemarker/core/ISOLikeTemplateTemporalFormat.java
new file mode 100644
index 0000000..8a8f8ff
--- /dev/null
+++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalFormat.java
@@ -0,0 +1,47 @@
+/*
+ * 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.format.DateTimeFormatter;
+
+// TODO [FREEMARKER-35] These should support parameters similar to {@link ISOTemplateDateFormat},
+final class ISOLikeTemplateTemporalFormat extends BaseJavaTemplateTemporalFormatTemplateFormat {
+    private final String description;
+
+    public ISOLikeTemplateTemporalFormat(DateTimeFormatter dateTimeFormatter, String description) {
+        super(dateTimeFormatter);
+        this.description = description;
+    }
+
+    @Override
+    public boolean isLocaleBound() {
+        return false;
+    }
+
+    @Override
+    public boolean isTimeZoneBound() {
+        return true;
+    }
+
+    @Override
+    public String getDescription() {
+        return description;
+    }
+}
diff --git a/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..58e47c0
--- /dev/null
+++ b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
@@ -0,0 +1,118 @@
+/*
+ * 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.LocalTime;
+import java.time.Year;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+
+class ISOTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
+
+    static final ISOTemplateTemporalFormatFactory INSTANCE = new ISOTemplateTemporalFormatFactory();
+
+    private static final DateTimeFormatter ISO8601_DATE_TIME_FORMAT = new DateTimeFormatterBuilder()
+            .append(DateTimeFormatter.ISO_LOCAL_DATE)
+            .optionalStart()
+            .appendLiteral('T')
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .optionalStart()
+            .appendOffsetId()
+            .optionalEnd()
+            .optionalEnd()
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    private static final DateTimeFormatter ISO8601_TIME_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    private static final DateTimeFormatter ISO8601_YEARMONTH_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(ChronoField.YEAR)
+            .appendLiteral("-")
+            .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    static final DateTimeFormatter ISO8601_YEAR_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(ChronoField.YEAR)
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    @Override
+    public TemplateTemporalFormat get(String params, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, Environment env) throws
+            TemplateValueFormatException {
+        if (!params.isEmpty()) {
+            // TODO [FREEMARKER-35]
+            throw new InvalidFormatParametersException("xs currently doesn't support parameters");
+        }
+
+        return getXSFormatter(temporalClass, timeZone.toZoneId());
+    }
+
+    private static ISOLikeTemplateTemporalFormat getXSFormatter(Class<? extends Temporal> temporalClass, ZoneId timeZone) {
+        final DateTimeFormatter dateTimeFormatter;
+        final String description;
+        if (temporalClass == LocalTime.class) {
+            dateTimeFormatter = ISO8601_TIME_FORMAT;
+            description = "ISO 8601 (subset) time";
+        } else if (temporalClass == Year.class) {
+            dateTimeFormatter = ISO8601_YEAR_FORMAT; // Same as ISO
+            description = "ISO 8601 (subset) year";
+        } else if (temporalClass == YearMonth.class) {
+            dateTimeFormatter = ISO8601_YEARMONTH_FORMAT;
+            description = "ISO 8601 (subset) year-month";
+        } else {
+            Class<? extends Temporal> normTemporalClass =
+                    _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+            if (normTemporalClass != temporalClass) {
+                return getXSFormatter(normTemporalClass, timeZone);
+            } else {
+                dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
+                description = "ISO 8601 (subset) date-time";
+            }
+        }
+        // TODO [FREEMARKER-35] What about date-only?
+        return new ISOLikeTemplateTemporalFormat(dateTimeFormatter.withZone(timeZone), description);
+    }
+
+}
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
new file mode 100644
index 0000000..001b870
--- /dev/null
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
@@ -0,0 +1,129 @@
+/*
+ * 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.Year;
+import java.time.YearMonth;
+import java.time.chrono.IsoChronology;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.FormatStyle;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.regex.Pattern;
+
+import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateTemporalModel;
+
+final class JavaTemplateTemporalFormat extends BaseJavaTemplateTemporalFormatTemplateFormat {
+    private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile("^(short|medium|long|full)(_(short|medium|long|full))?$");
+
+    // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did
+    private static final DateTimeFormatter SHORT_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
+    private static final DateTimeFormatter MEDIUM_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
+    private static final DateTimeFormatter LONG_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG);
+    private static final DateTimeFormatter FULL_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL);
+
+    private final String formatString;
+
+    JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone)
+            throws InvalidFormatParametersException {
+        super(getDateTimeFormat(formatString, temporalClass, locale, timeZone));
+        this.formatString = formatString;
+    }
+
+    private static DateTimeFormatter getDateTimeFormat(String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone) throws
+            InvalidFormatParametersException {
+        DateTimeFormatter result;
+        if (FORMAT_STYLE_PATTERN.matcher(formatString).matches()) {
+            // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did
+            boolean isYear = Year.class.isAssignableFrom(temporalClass);
+            boolean isYearMonth = YearMonth.class.isAssignableFrom(temporalClass);
+            String[] formatSplt = formatString.split("_");
+            if (isYear || isYearMonth) {
+                String reducedPattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(FormatStyle.valueOf(formatSplt[0].toUpperCase()), null, IsoChronology.INSTANCE, locale);
+                if (isYear)
+                    result = DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, false));
+                else
+                    result = DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, true));
+            } else if ("short".equals(formatString))
+                result =  SHORT_FORMAT;
+            else if ("medium".equals(formatString))
+                result =  MEDIUM_FORMAT;
+            else if ("long".equals(formatString))
+                result =  LONG_FORMAT;
+            else if ("full".equals(formatString))
+                result = FULL_FORMAT;
+            else
+                result = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.valueOf(formatSplt[0].toUpperCase()), FormatStyle.valueOf(formatSplt[1].toUpperCase()));
+        } else {
+            try {
+                result = DateTimeFormatter.ofPattern(formatString);
+            } catch (IllegalArgumentException e) {
+                throw new InvalidFormatParametersException(e.getMessage(), e);
+            }
+        }
+        return result.withLocale(locale).withZone(timeZone.toZoneId());
+    }
+
+    // TODO [FREEMARKER-35] This override should be unecessary. Move logic here into getDateTimeFormat somehow.
+    @Override
+    public String format(TemplateTemporalModel tm) throws TemplateValueFormatException, TemplateModelException {
+        return super.format(tm);
+    }
+
+    @Override
+    public String getDescription() {
+        return formatString;
+    }
+
+    /**
+     * Tells if this formatter should be re-created if the locale changes.
+     */
+    @Override
+    public boolean isLocaleBound() {
+        return true;
+    }
+
+    /**
+     * Tells if this formatter should be re-created if the time zone changes.
+     */
+    @Override
+    public boolean isTimeZoneBound() {
+        return true;
+    }
+
+    // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did
+    private static String removeNonYM(String pattern, boolean withMonth) {
+        boolean separator = false;
+        boolean copy = true;
+        StringBuilder newPattern = new StringBuilder();
+        for (char c : pattern.toCharArray()) {
+            if (c == '\'')
+                separator = !separator;
+            if (!separator && Character.isAlphabetic(c))
+                copy = c == 'y' || c == 'u' || (withMonth && (c == 'M' || c == 'L'));
+            if (copy)
+                newPattern.append(c);
+        }
+        return newPattern.toString();
+    }
+
+}
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/JavaTemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..5e92a89
--- /dev/null
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormatFactory.java
@@ -0,0 +1,42 @@
+/*
+ * 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.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.regex.Pattern;
+
+class JavaTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
+    public static final JavaTemplateTemporalFormatFactory INSTANCE = new JavaTemplateTemporalFormatFactory();
+
+    private JavaTemplateTemporalFormatFactory() {
+        // Not instantiated from outside
+    }
+
+    @Override
+    public TemplateTemporalFormat get(String params, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone,
+            Environment env) throws TemplateValueFormatException {
+        return new JavaTemplateTemporalFormat(params, temporalClass, locale, timeZone);
+    }
+
+}
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java b/src/main/java/freemarker/core/TemplateTemporalFormat.java
index aec3844..9fa51ef 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/TemplateTemporalFormat.java
@@ -18,45 +18,35 @@
  */
 package freemarker.core;
 
-import java.util.Locale;
-import java.util.TimeZone;
+import java.time.format.DateTimeFormatter;
 
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
-import freemarker.template.utility.TemporalUtil;
 
-public class TemplateTemporalFormat extends TemplateValueFormat {
-    private final String format;
-    private final Locale locale;
-    private final TimeZone timeZone;
-
-    public TemplateTemporalFormat(String format, Locale locale, TimeZone timeZone) {
-        this.format = format;
-        this.locale = locale;
-        this.timeZone = timeZone;
-    }
-
-    public String format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, TemplateModelException {
-        return TemporalUtil.format(temporalModel.getAsTemporal(), format, locale, timeZone);
-    }
+/**
+ * Represents a {@link Temporal} format; used in templates for formatting and parsing with that format. This is
+ * similar to Java's {@link DateTimeFormatter}, but made to fit the requirements of FreeMarker. Also, it makes easier to
+ * define formats that can't be represented with {@link DateTimeFormatter}.
+ *
+ * <p>
+ * Implementations need not be thread-safe if the {@link TemplateTemporalFormatFactory} doesn't recycle them among
+ * different {@link Environment}-s. As far as FreeMarker's concerned, instances are bound to a single
+ * {@link Environment}, and {@link Environment}-s are thread-local objects.
+ *
+ * @since 2.3.31
+ */
+public abstract class TemplateTemporalFormat extends TemplateValueFormat {
 
-    @Override
-    public String getDescription() {
-        return format + " " + locale.toString();
-    }
+    public abstract String format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, TemplateModelException;
 
     /**
      * Tells if this formatter should be re-created if the locale changes.
      */
-    public boolean isLocaleBound() {
-        return true;
-    }
+    public abstract boolean isLocaleBound();
 
     /**
      * Tells if this formatter should be re-created if the time zone changes.
      */
-    public boolean isTimeZoneBound() {
-        return true;
-    }
+    public abstract boolean isTimeZoneBound();
 
 }
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..ae3971b
--- /dev/null
+++ b/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java
@@ -0,0 +1,81 @@
+/*
+ * 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.text.SimpleDateFormat;
+import java.time.temporal.Temporal;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import freemarker.template.Configuration;
+import freemarker.template.TemplateDateModel;
+
+/**
+ * Factory for a certain kind of {@link Temporal} formatting ({@link TemplateTemporalFormat}). Usually a singleton
+ * (one-per-VM or one-per-{@link Configuration}), and so must be thread-safe.
+ * 
+ * TODO [FREEMARKER-35] @see Configurable#setCustomTemporalFormats(java.util.Map)
+ * 
+ * @since 2.3.24
+ */
+public abstract class TemplateTemporalFormatFactory extends TemplateValueFormatFactory {
+    
+    /**
+     * Returns a formatter for the given parameters.
+     * 
+     * <p>
+     * The returned formatter can be a new instance or a reused (cached) instance. Note that {@link Environment} itself
+     * caches the returned instances, though that cache is lost with the {@link Environment} (i.e., when the top-level
+     * template execution ends), also it might flushes lot of entries if the locale or time zone is changed during
+     * template execution. So caching on the factory level is still useful, unless creating the formatters is
+     * sufficiently cheap.
+     * 
+     * @param params
+     *            The string that further describes how the format should look. For example, when the
+     *            {@link Configurable#getInstantFormat()} ()} instantFormat} is {@code "@fooBar 1, 2"}, then it will be
+     *            {@code "1, 2"} (and {@code "@fooBar"} selects the factory). The format of this string is up to the
+     *            {@link TemplateTemporalFormatFactory} implementation. Not {@code null}, often an empty string.
+     * @param temporalClass
+     *            The type of the temporal. If this type is not supported, the method should throw an
+     *            {@link UnformattableTemporalTypeException} exception.
+     * @param locale
+     *            The locale to format for. Not {@code null}. The resulting format should be bound to this locale
+     *            forever (i.e. locale changes in the {@link Environment} must not be followed).
+     * @param timeZone
+     *            The time zone to format for. Not {@code null}. The resulting format must be bound to this time zone
+     *            forever (i.e. time zone changes in the {@link Environment} must not be followed).
+     * @param env
+     *            The runtime environment from which the formatting was called. This is mostly meant to be used for
+     *            {@link Environment#setCustomState(Object, Object)}/{@link Environment#getCustomState(Object)}. The
+     *            result shouldn't depend on setting values in the {@link Environment}, as changing other setting
+     *            will not necessarily invalidate the result.
+     * 
+     * @throws TemplateValueFormatException
+     *             If any problem occurs while parsing/getting the format. Notable subclasses:
+     *             {@link InvalidFormatParametersException} if {@code params} is malformed;
+     *             {@link UnformattableTemporalTypeException} if the {@code temporalClass} subclass is
+     *             not supported by this factory.
+     */
+    public abstract TemplateTemporalFormat get(
+            String params,
+            Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, Environment env)
+                    throws TemplateValueFormatException;
+
+}
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java b/src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java
similarity index 59%
copy from src/main/java/freemarker/core/TemplateTemporalFormat.java
copy to src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java
index aec3844..3bd6d64 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java
@@ -16,47 +16,49 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+
 package freemarker.core;
 
-import java.util.Locale;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.temporal.Temporal;
+import java.util.Objects;
 import java.util.TimeZone;
 
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
-import freemarker.template.utility.TemporalUtil;
 
-public class TemplateTemporalFormat extends TemplateValueFormat {
-    private final String format;
-    private final Locale locale;
-    private final TimeZone timeZone;
+class ToStringTemplateTemporalFormat extends TemplateTemporalFormat {
 
-    public TemplateTemporalFormat(String format, Locale locale, TimeZone timeZone) {
-        this.format = format;
-        this.locale = locale;
-        this.timeZone = timeZone;
-    }
+    private final ZoneId timeZone;
 
-    public String format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, TemplateModelException {
-        return TemporalUtil.format(temporalModel.getAsTemporal(), format, locale, timeZone);
+    ToStringTemplateTemporalFormat(TimeZone timeZone) {
+        this.timeZone = timeZone.toZoneId();
     }
 
     @Override
-    public String getDescription() {
-        return format + " " + locale.toString();
+    public String format(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();
     }
 
-    /**
-     * Tells if this formatter should be re-created if the locale changes.
-     */
+    @Override
     public boolean isLocaleBound() {
-        return true;
+        return false;
     }
 
-    /**
-     * Tells if this formatter should be re-created if the time zone changes.
-     */
+    @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
new file mode 100644
index 0000000..951471e
--- /dev/null
+++ b/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java
@@ -0,0 +1,42 @@
+/*
+ * 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;
+
+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);
+    }
+}
diff --git a/src/main/java/freemarker/core/UnformattableTemporalTypeException.java b/src/main/java/freemarker/core/UnformattableTemporalTypeException.java
new file mode 100644
index 0000000..a4f377a
--- /dev/null
+++ b/src/main/java/freemarker/core/UnformattableTemporalTypeException.java
@@ -0,0 +1,38 @@
+/*
+ * 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 freemarker.template.TemplateTemporalModel;
+
+/**
+ * Thrown when a {@link TemplateTemporalModel} can't be formatted because the {@link TemplateTemporalFormatFactory}
+ * doesn't support it.
+ *
+ * @since 2.3.31
+ */
+public final class UnformattableTemporalTypeException extends UnformattableValueException {
+
+    public UnformattableTemporalTypeException(Class<? extends Temporal> temporalClass) {
+        super("Temporal type not supported: " + temporalClass.getName());
+    }
+    
+}
diff --git a/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..1f36313
--- /dev/null
+++ b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
@@ -0,0 +1,124 @@
+/*
+ * 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.core.ISOTemplateTemporalFormatFactory.*;
+
+import java.time.LocalTime;
+import java.time.Year;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+
+class XSTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
+
+    static final XSTemplateTemporalFormatFactory INSTANCE = new XSTemplateTemporalFormatFactory();
+
+    private XSTemplateTemporalFormatFactory() {
+        // Not meant to be called from outside
+    }
+
+    private final static DateTimeFormatter XSD_DATE_TIME_FORMAT = new DateTimeFormatterBuilder()
+            .append(DateTimeFormatter.ISO_LOCAL_DATE)
+            .optionalStart()
+            .appendLiteral('T')
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .optionalEnd()
+            .optionalStart()
+            .appendOffsetId()
+            .optionalEnd()
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    private final static DateTimeFormatter XSD_TIME_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .optionalStart()
+            .appendOffsetId()
+            .optionalEnd()
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    private static final DateTimeFormatter XSD_YEARMONTH_FORMAT = new DateTimeFormatterBuilder()
+            .appendValue(ChronoField.YEAR)
+            .appendLiteral("-")
+            .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+            .optionalStart()
+            .appendOffsetId()
+            .optionalEnd()
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    @Override
+    public TemplateTemporalFormat get(String params, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, Environment env) throws
+            TemplateValueFormatException {
+        if (!params.isEmpty()) {
+            // TODO [FREEMARKER-35]
+            throw new InvalidFormatParametersException("xs currently doesn't support parameters");
+        }
+
+        return getXSFormatter(temporalClass, timeZone.toZoneId());
+    }
+
+    private static ISOLikeTemplateTemporalFormat getXSFormatter(Class<? extends Temporal> temporalClass, ZoneId timeZone) {
+        final DateTimeFormatter dateTimeFormatter;
+        final String description;
+        if (temporalClass == LocalTime.class) {
+            dateTimeFormatter = XSD_TIME_FORMAT;
+            description = "W3C XML Schema time";
+        } else if (temporalClass == Year.class) {
+            dateTimeFormatter = ISO8601_YEAR_FORMAT; // Same as ISO
+            description = "W3C XML Schema year";
+        } else if (temporalClass == YearMonth.class) {
+            dateTimeFormatter = XSD_YEARMONTH_FORMAT;
+            description = "W3C XML Schema year-month";
+        } else {
+            Class<? extends Temporal> normTemporalClass =
+                    _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+            if (normTemporalClass != temporalClass) {
+                return getXSFormatter(normTemporalClass, timeZone);
+            } else {
+                dateTimeFormatter = XSD_DATE_TIME_FORMAT;
+                description = "W3C XML Schema date-time";
+            }
+        }
+        // TODO [FREEMARKER-35] What about date-only?
+        return new ISOLikeTemplateTemporalFormat(dateTimeFormatter.withZone(timeZone), description);
+    }
+
+}
diff --git a/src/main/java/freemarker/core/_CoreTemporalUtils.java b/src/main/java/freemarker/core/_CoreTemporalUtils.java
index 027c13d..f95906c 100644
--- a/src/main/java/freemarker/core/_CoreTemporalUtils.java
+++ b/src/main/java/freemarker/core/_CoreTemporalUtils.java
@@ -28,12 +28,15 @@ import java.time.OffsetDateTime;
 import java.time.OffsetTime;
 import java.time.Year;
 import java.time.YearMonth;
+import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.time.temporal.Temporal;
 import java.util.Arrays;
 import java.util.List;
 import java.util.stream.Stream;
 
+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
@@ -85,15 +88,44 @@ public class _CoreTemporalUtils {
                 return OffsetDateTime.class;
             } else if (OffsetTime.class.isAssignableFrom(temporalClass)) {
                 return OffsetTime.class;
-            } else if (Year.class.isAssignableFrom(temporalClass)) {
-                return Year.class;
-            } else if (YearMonth.class.isAssignableFrom(temporalClass)) {
-                return YearMonth.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) {
+            return Configuration.INSTANT_FORMAT_KEY;
+        } else if (temporalClass == LocalDate.class) {
+            return Configuration.LOCAL_DATE_FORMAT_KEY;
+        } else if (temporalClass == LocalDateTime.class) {
+            return Configuration.LOCAL_DATE_TIME_FORMAT_KEY;
+        } else if (temporalClass == LocalTime.class) {
+            return Configuration.LOCAL_TIME_FORMAT_KEY;
+        } else if (temporalClass == OffsetDateTime.class) {
+            return Configuration.OFFSET_DATE_TIME_FORMAT_KEY;
+        } else if (temporalClass == OffsetTime.class) {
+            return Configuration.OFFSET_TIME_FORMAT_KEY;
+        } else if (temporalClass == ZonedDateTime.class) {
+            return Configuration.ZONED_DATE_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/core/_MessageUtil.java b/src/main/java/freemarker/core/_MessageUtil.java
index 7867f66..034ec0f 100644
--- a/src/main/java/freemarker/core/_MessageUtil.java
+++ b/src/main/java/freemarker/core/_MessageUtil.java
@@ -322,10 +322,10 @@ public class _MessageUtil {
                 : new _MiscTemplateException(e, null, desc);
     }
     
-    public static TemplateException newCantFormatDateException(TemplateTemporalFormat format, Expression dataSrcExp,
+    public static TemplateException newCantFormatTemporalException(TemplateTemporalFormat format, Expression dataSrcExp,
             TemplateValueFormatException e, boolean useTempModelExc) {
         _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder(
-                "Failed to format date/time/datetime with format ", new _DelayedJQuote(format.getDescription()), ": ",
+                "Failed to format temporal value with format ", new _DelayedJQuote(format.getDescription()), ": ",
                 e.getMessage())
                 .blame(dataSrcExp);
         return useTempModelExc
diff --git a/src/main/java/freemarker/template/utility/TemporalUtil.java b/src/main/java/freemarker/template/utility/TemporalUtil.java
deleted file mode 100644
index a1c9d73..0000000
--- a/src/main/java/freemarker/template/utility/TemporalUtil.java
+++ /dev/null
@@ -1,197 +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.template.utility;
-
-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.ZoneOffset;
-import java.time.ZonedDateTime;
-import java.time.chrono.IsoChronology;
-import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeFormatterBuilder;
-import java.time.format.FormatStyle;
-import java.time.temporal.ChronoField;
-import java.time.temporal.Temporal;
-import java.util.Locale;
-import java.util.TimeZone;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
-
-public class TemporalUtil {
-	private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile("^(short|medium|long|full)(_(short|medium|long|full))?$");
-	private final static DateTimeFormatter SHORT_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
-	private final static DateTimeFormatter MEDIUM_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
-	private final static DateTimeFormatter LONG_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG);
-	private final static DateTimeFormatter FULL_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL);
-
-	private final static DateTimeFormatter XSD_FORMAT = new DateTimeFormatterBuilder()
-			.append(DateTimeFormatter.ISO_LOCAL_DATE)
-			.optionalStart()
-			.appendLiteral('T')
-			.appendValue(ChronoField.HOUR_OF_DAY, 2)
-			.appendLiteral(":")
-			.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
-			.appendLiteral(":")
-			.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-			.appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
-			.optionalEnd()
-				.optionalStart()
-			.appendOffsetId()
-			.optionalEnd()
-			.toFormatter();
-	private final static DateTimeFormatter XSD_TIME_FORMAT = new DateTimeFormatterBuilder()
-			.appendValue(ChronoField.HOUR_OF_DAY, 2)
-			.appendLiteral(":")
-			.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
-			.appendLiteral(":")
-			.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-			.appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
-			.optionalStart()
-				.appendOffsetId()
-			.optionalEnd()
-			.toFormatter();
-	public static final DateTimeFormatter XSD_YEARMONTH_FORMAT = new DateTimeFormatterBuilder()
-			.appendValue(ChronoField.YEAR)
-			.appendLiteral("-")
-			.appendValue(ChronoField.MONTH_OF_YEAR, 2)
-			.optionalStart()
-				.appendOffsetId()
-			.optionalEnd()
-			.toFormatter();
-
-	public static final DateTimeFormatter ISO8601_FORMAT = new DateTimeFormatterBuilder()
-			.append(DateTimeFormatter.ISO_LOCAL_DATE)
-			.optionalStart()
-				.appendLiteral('T')
-				.appendValue(ChronoField.HOUR_OF_DAY, 2)
-				.appendLiteral(":")
-				.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
-				.appendLiteral(":")
-				.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-				.appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
-				.optionalStart()
-					.appendOffsetId()
-				.optionalEnd()
-			.optionalEnd()
-			.toFormatter();
-	public static final DateTimeFormatter ISO8601_TIME_FORMAT = new DateTimeFormatterBuilder()
-			.appendValue(ChronoField.HOUR_OF_DAY, 2)
-			.appendLiteral(":")
-			.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
-			.appendLiteral(":")
-			.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-			.appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
-			.toFormatter();
-	public static final DateTimeFormatter ISO8601_YEARMONTH_FORMAT = new DateTimeFormatterBuilder()
-			.appendValue(ChronoField.YEAR)
-			.appendLiteral("-")
-			.appendValue(ChronoField.MONTH_OF_YEAR, 2)
-			.toFormatter();
-	public static final DateTimeFormatter ISO8601_YEAR_FORMAT = new DateTimeFormatterBuilder()
-			.appendValue(ChronoField.YEAR)
-			.toFormatter();
-
-	private static DateTimeFormatter getISO8601Formatter(Temporal temporal) {
-		if (temporal instanceof LocalTime)
-			return ISO8601_TIME_FORMAT;
-		else if (temporal instanceof Year)
-			return ISO8601_YEAR_FORMAT;
-		else if (temporal instanceof YearMonth)
-			return ISO8601_YEARMONTH_FORMAT;
-		else
-			return ISO8601_FORMAT;
-	}
-
-	private static DateTimeFormatter getXSFormatter(Temporal temporal) {
-		if (temporal instanceof LocalTime)
-			return XSD_TIME_FORMAT;
-		else if (temporal instanceof Year)
-			return ISO8601_YEAR_FORMAT;//ISO same as XSD here
-		else if (temporal instanceof YearMonth)
-			return XSD_YEARMONTH_FORMAT;
-		else
-			return XSD_FORMAT;
-	}
-
-	public static String format(Temporal temporal, String format, Locale locale, TimeZone timeZone) {
-		//TODO: cache these DateTimeFormatter instances (withLocale & withZone create new instances too, when they differ from the instance)
-		if (temporal instanceof Instant)
-			temporal = ((Instant) temporal).atZone(timeZone == null ? ZoneOffset.UTC : timeZone.toZoneId());
-
-		DateTimeFormatter dtf;
-		if ("xs".equals(format))
-			dtf = getXSFormatter(temporal);
-		else if ("iso".equals(format))
-			dtf =  getISO8601Formatter(temporal);
-		else if (FORMAT_STYLE_PATTERN.matcher(format).matches()) {
-			boolean isYear = temporal instanceof Year;
-			boolean isYearMonth = temporal instanceof YearMonth;
-			String[] formatSplt = format.split("_");
-			if (isYear || isYearMonth) {
-				String reducedPattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(FormatStyle.valueOf(formatSplt[0].toUpperCase()), null, IsoChronology.INSTANCE, locale);
-				if (isYear)
-					dtf = DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, false));
-				else
-					dtf = DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, true));
-			} else if ("short".equals(format))
-				dtf =  SHORT_FORMAT;
-			else if ("medium".equals(format))
-				dtf =  MEDIUM_FORMAT;
-			else if ("long".equals(format))
-				dtf =  LONG_FORMAT;
-			else if ("full".equals(format))
-				dtf = FULL_FORMAT;
-			else
-				dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.valueOf(formatSplt[0].toUpperCase()), FormatStyle.valueOf(formatSplt[1].toUpperCase()));
-		} else if (!"".equals(format))
-			dtf = DateTimeFormatter.ofPattern(format);
-		else
-			return temporal.toString();
-
-		dtf = dtf.withLocale(locale);
-		if (temporal instanceof OffsetDateTime)
-			dtf = dtf.withZone(((OffsetDateTime) temporal).getOffset());
-		else if (!(temporal instanceof ZonedDateTime))
-			dtf = dtf.withZone(timeZone.toZoneId());
-		return dtf.format(temporal);
-	}
-
-	private static String removeNonYM(String pattern, boolean withMonth) {
-		boolean separator = false;
-		boolean copy = true;
-		StringBuilder newPattern = new StringBuilder();
-		for (char c : pattern.toCharArray()) {
-			if (c == '\'')
-				separator = !separator;
-			if (!separator && Character.isAlphabetic(c))
-				copy = c == 'y' || c == 'u' || (withMonth && (c == 'M' || c == 'L'));
-			if (copy)
-				newPattern.append(c);
-		}
-		return newPattern.toString();
-	}
-
-}
diff --git a/src/test/java/freemarker/core/TemporalConfigurableTest.java b/src/test/java/freemarker/core/CoreTemporalUtilTest.java
similarity index 67%
rename from src/test/java/freemarker/core/TemporalConfigurableTest.java
rename to src/test/java/freemarker/core/CoreTemporalUtilTest.java
index 92cb575..2eab1f5 100644
--- a/src/test/java/freemarker/core/TemporalConfigurableTest.java
+++ b/src/test/java/freemarker/core/CoreTemporalUtilTest.java
@@ -21,15 +21,16 @@ package freemarker.core;
 
 import static org.junit.Assert.*;
 
-import java.time.Instant;
 import java.time.chrono.ChronoLocalDate;
 import java.time.temporal.Temporal;
+import java.util.HashSet;
+import java.util.Set;
 
 import org.junit.Test;
 
 import freemarker.template.Configuration;
 
-public class TemporalConfigurableTest {
+public class CoreTemporalUtilTest {
 
     @Test
     public void testSupportedTemporalClassAreFinal() {
@@ -42,12 +43,31 @@ public class TemporalConfigurableTest {
     @Test
     public void testGetTemporalFormat() {
         Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
+
         for (Class<? extends Temporal> supportedTemporalClass : _CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
             assertNotNull(cfg.getTemporalFormat(supportedTemporalClass));
         }
 
         try {
-            assertNotNull(cfg.getTemporalFormat(ChronoLocalDate.class));
+            cfg.getTemporalFormat(ChronoLocalDate.class);
+            fail();
+        } catch (IllegalArgumentException e) {
+            // Expected
+        }
+    }
+
+    @Test
+    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) {
+            assertTrue(uniqueSettingNames.add(_CoreTemporalUtils.temporalClassToFormatSettingName(supportedTemporalClass)));
+        }
+        assertTrue(uniqueSettingNames.stream().allMatch(it -> cfg.getSettingNames(false).contains(it)));
+
+        try {
+            _CoreTemporalUtils.temporalClassToFormatSettingName(ChronoLocalDate.class);
             fail();
         } catch (IllegalArgumentException e) {
             // Expected
diff --git a/src/test/java/freemarker/core/TemporalErrorMessagesTest.java b/src/test/java/freemarker/core/TemporalErrorMessagesTest.java
new file mode 100644
index 0000000..e9c2791
--- /dev/null
+++ b/src/test/java/freemarker/core/TemporalErrorMessagesTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.LocalTime;
+
+import org.junit.Test;
+
+import freemarker.template.TemplateException;
+import freemarker.test.TemplateTest;
+
+public class TemporalErrorMessagesTest extends TemplateTest {
+
+    @Test
+    public void testExplicitFormatString() throws TemplateException {
+        addToDataModel("t", LocalTime.now());
+        assertErrorContains("${t?string('yyyy-HH')}", "Failed to format temporal value", "yyyy-HH", "YearOfEra");
+    }
+
+    @Test
+    public void testDefaultFormatStringBadFormatString() throws TemplateException {
+        getConfiguration().setSetting("local_time_format", "ABCDEF");
+        addToDataModel("t", LocalTime.now());
+        assertErrorContains("${t}", "local_time_format", "ABCDEF");
+        assertErrorContains("${t?string}", "local_time_format", "ABCDEF");
+    }
+
+    @Test
+    public void testDefaultFormatStringIncompatibleFormatString() throws TemplateException {
+        getConfiguration().setSetting("local_time_format", "yyyy-HH");
+        addToDataModel("t", LocalTime.now());
+        // TODO [FREEMARKER-35] Should contain "local_time_format" too
+        assertErrorContains("${t}", "Failed to format temporal value", "yyyy-HH", "YearOfEra");
+        assertErrorContains("${t?string}", "Failed to format temporal value", "yyyy-HH", "YearOfEra");
+    }
+
+}
diff --git a/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java b/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java
index 4522026..1ab62ab 100644
--- a/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java
+++ b/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java
@@ -27,6 +27,7 @@ import java.math.BigDecimal;
 import java.math.BigInteger;
 import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
+import java.time.OffsetTime;
 import java.time.Year;
 import java.time.YearMonth;
 import java.time.ZoneId;
@@ -365,6 +366,7 @@ public class TemplateTestCase extends FileTestCase {
             dataModel.put("yearMonth", YearMonth.from(ldt));
             ZonedDateTime zdt = ldt.atZone(ZoneId.of("UTC"));
             dataModel.put("offsetDateTime", zdt.toOffsetDateTime());
+            dataModel.put("offsetTime", zdt.toOffsetDateTime().toOffsetTime());
             dataModel.put("zonedDateTime", zdt);
         } else if (simpleTestName.equals("var-layers")) {
             dataModel.put("x", Integer.valueOf(4));
diff --git a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
index 72d44ad..cbc5fd1 100644
--- a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
+++ b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
@@ -21,6 +21,7 @@
 <@assertEquals expected="2003-04-05" actual=localDate?string />
 <@assertEquals expected="06:07:08" actual=localTime?string />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string />
+<@assertEquals expected="06:07:08Z" actual=offsetTime?string />
 <@assertEquals expected="2003" actual=year?string />
 <@assertEquals expected="2003-04" actual=yearMonth?string />
 <@assertEquals expected="2003-04-05T06:07:08Z[UTC]" actual=zonedDateTime?string />
@@ -31,6 +32,7 @@
 <@assertEquals expected="2003-04-05" actual=localDate?string.iso />
 <@assertEquals expected="06:07:08" actual=localTime?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string.iso />
+<@assertEquals expected="06:07:08Z" actual=offsetTime?string />
 <@assertEquals expected="2003" actual=year?string.iso />
 <@assertEquals expected="2003-04" actual=yearMonth?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=zonedDateTime?string.iso />
@@ -41,6 +43,7 @@
 <@assertEquals expected="2003-04-05" actual=localDate?string.iso />
 <@assertEquals expected="06:07:08" actual=localTime?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string.iso />
+<@assertEquals expected="06:07:08Z" actual=offsetTime?string />
 <@assertEquals expected="2003" actual=year?string.iso />
 <@assertEquals expected="2003-04" actual=yearMonth?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=zonedDateTime?string.iso />
@@ -50,6 +53,7 @@
 <@assertEquals expected="2003-04-05" actual=localDate?string.xs />
 <@assertEquals expected="06:07:08" actual=localTime?string.xs />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string.xs />
+<@assertEquals expected="06:07:08Z" actual=offsetTime?string />
 <@assertEquals expected="2003" actual=year?string.xs />
 <@assertEquals expected="2003-04" actual=yearMonth?string.xs />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=zonedDateTime?string.xs />