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 2021/11/03 23:43:29 UTC

[freemarker] branch FREEMARKER-35 updated: [FREEMARKER-35] Added custom format support to temporals. Fixed TemplateTemporalFormat.format return type.

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


The following commit(s) were added to refs/heads/FREEMARKER-35 by this push:
     new 8a9ec95  [FREEMARKER-35] Added custom format support to temporals. Fixed TemplateTemporalFormat.format return type.
8a9ec95 is described below

commit 8a9ec953d64cde5ed17ead3b87180f90ee6be898
Author: ddekany <dd...@apache.org>
AuthorDate: Thu Nov 4 00:42:13 2021 +0100

    [FREEMARKER-35] Added custom format support to temporals. Fixed TemplateTemporalFormat.format return type.
---
 src/main/java/freemarker/core/Configurable.java    | 150 ++++++++++++++++++---
 .../freemarker/core/TemplateConfiguration.java     |   8 ++
 .../java/freemarker/core/TemplateFormatUtil.java   |  18 ++-
 .../freemarker/core/TemplateTemporalFormat.java    |   2 +-
 .../core/TemplateTemporalFormatFactory.java        |   4 +-
 .../core/BaseNTemplateNumberFormatFactory.java     |   2 +-
 .../EpochMillisDivTemplateDateFormatFactory.java   |   2 +-
 ...ochMillisDivTemplateTemporalFormatFactory.java} |  47 ++++---
 .../EpochMillisTemplateTemporalFormatFactory.java  |  83 ++++++++++++
 .../core/HTMLISOTemplateTemporalFormatFactory.java |  86 ++++++++++++
 ...LocAndTZSensitiveTemplateDateFormatFactory.java |   2 +-
 ...=> LocAndTZSensitiveTemporalFormatFactory.java} |  48 ++++---
 .../freemarker/core/TemplateConfigurationTest.java |   2 +
 .../java/freemarker/core/TemporalFormatTest2.java  |  76 +++++++++++
 14 files changed, 454 insertions(+), 76 deletions(-)

diff --git a/src/main/java/freemarker/core/Configurable.java b/src/main/java/freemarker/core/Configurable.java
index bce76e8..a3d2d64 100644
--- a/src/main/java/freemarker/core/Configurable.java
+++ b/src/main/java/freemarker/core/Configurable.java
@@ -33,7 +33,6 @@ import java.time.OffsetTime;
 import java.time.Year;
 import java.time.YearMonth;
 import java.time.ZonedDateTime;
-import java.time.format.DateTimeFormatter;
 import java.time.format.FormatStyle;
 import java.time.temporal.Temporal;
 import java.util.ArrayList;
@@ -151,6 +150,13 @@ public class Configurable {
     /** Alias to the {@code ..._SNAKE_CASE} variation due to backward compatibility constraints. */
     public static final String DATETIME_FORMAT_KEY = DATETIME_FORMAT_KEY_SNAKE_CASE;
 
+    /** Legacy, snake case ({@code like_this}) variation of the setting name. @since 2.3.32 */
+    public static final String CUSTOM_TEMPORAL_FORMATS_KEY_SNAKE_CASE = "custom_temporal_formats";
+    /** Modern, camel case ({@code likeThis}) variation of the setting name. @since 2.3.32 */
+    public static final String CUSTOM_TEMPORAL_FORMATS_KEY_CAMEL_CASE = "customTemporalFormats";
+    /** Alias to the {@code ..._SNAKE_CASE} variation due to backward compatibility constraints. */
+    public static final String CUSTOM_TEMPORAL_FORMATS_KEY = CUSTOM_TEMPORAL_FORMATS_KEY_SNAKE_CASE;
+
     public static final String INSTANT_FORMAT_KEY_SNAKE_CASE = "instant_format";
     public static final String INSTANT_FORMAT_KEY_CAMEL_CASE = "instantFormat";
     public static final String INSTANT_FORMAT_KEY = INSTANT_FORMAT_KEY_SNAKE_CASE;
@@ -357,6 +363,7 @@ public class Configurable {
         CLASSIC_COMPATIBLE_KEY_SNAKE_CASE,
         CUSTOM_DATE_FORMATS_KEY_SNAKE_CASE,
         CUSTOM_NUMBER_FORMATS_KEY_SNAKE_CASE,
+        CUSTOM_TEMPORAL_FORMATS_KEY_SNAKE_CASE,
         DATE_FORMAT_KEY_SNAKE_CASE,
         DATETIME_FORMAT_KEY_SNAKE_CASE,
         INSTANT_FORMAT_KEY_SNAKE_CASE,
@@ -399,6 +406,7 @@ public class Configurable {
         CLASSIC_COMPATIBLE_KEY_CAMEL_CASE,
         CUSTOM_DATE_FORMATS_KEY_CAMEL_CASE,
         CUSTOM_NUMBER_FORMATS_KEY_CAMEL_CASE,
+        CUSTOM_TEMPORAL_FORMATS_KEY_CAMEL_CASE,
         DATE_FORMAT_KEY_CAMEL_CASE,
         DATETIME_FORMAT_KEY_CAMEL_CASE,
         INSTANT_FORMAT_KEY_CAMEL_CASE,
@@ -470,6 +478,7 @@ public class Configurable {
     private Boolean logTemplateExceptions;
     private Boolean wrapUncheckedExceptions;
     private Map<String, ? extends TemplateDateFormatFactory> customDateFormats;
+    private Map<String, ? extends TemplateTemporalFormatFactory> customTemporalFormats;
     private Map<String, ? extends TemplateNumberFormatFactory> customNumberFormats;
     private LinkedHashMap<String, String> autoImports;
     private ArrayList<String> autoIncludes;
@@ -588,6 +597,7 @@ public class Configurable {
         customAttributes = new HashMap();
         
         customDateFormats = Collections.emptyMap();
+        customTemporalFormats = Collections.emptyMap();
         customNumberFormats = Collections.emptyMap();
         
         lazyImports = false;
@@ -1023,7 +1033,7 @@ public class Configurable {
                 throw new IllegalArgumentException("Format name must start with letter: " + name);
             }
             for (int i = 1; i < name.length(); i++) {
-                // Note that we deliberately don't allow "_" here.
+                // We don't allow "_" here, as that's used to separate the parameters from the name at some places.
                 if (!Character.isLetterOrDigit(name.charAt(i))) {
                     throw new IllegalArgumentException("Format name can only contain letters and digits: " + name);
                 }
@@ -1041,7 +1051,11 @@ public class Configurable {
     }
 
     /**
-     * Gets the custom name format registered for the name.
+     * Gets the custom number format factory registered for the name, or {@code null} if no format with the given name
+     * was registered.
+     *
+     * @name The name of the custom format; do not start it with {@code '@'}!
+     * @return The format factory, or {@code null}
      * 
      * @since 2.3.24
      */
@@ -1064,7 +1078,8 @@ public class Configurable {
     public boolean hasCustomFormats() {
         return customNumberFormats != null && !customNumberFormats.isEmpty()
                 || customDateFormats != null && !customDateFormats.isEmpty()
-                || getParent() != null && getParent().hasCustomFormats(); 
+                || customTemporalFormats != null && !customTemporalFormats.isEmpty()
+                || getParent() != null && getParent().hasCustomFormats();
     }
     
     /**
@@ -1602,8 +1617,8 @@ public class Configurable {
      *             conversion mentioned earlier), and will show months with numbers, while "long" and "full" will show
      *             the zone and/or offset, and shows months with their names. (Also "long" and "full" before Java 9
      *             fails for {@link LocalDateTime} and {@link LocalTime}, because of bug JDK-8085887.)
-     *         <li>Other: Interpreted as pattern via {@link DateTimeFormatter#ofPattern}. Example:
-     *             {@code "yyyy-MM-dd HH:mm:ss X"}.
+     *          <li>Anything that starts with {@code "@"} followed by a letter is interpreted as a custom temporal
+     *              format ({@link #setCustomTemporalFormats(Map)}).
      *     </ul>
      *
      * @since 2.3.32
@@ -1749,7 +1764,7 @@ public class Configurable {
      * that value, otherwise it returns the value from the parent {@link Configurable}. So beware, the returned value
      * doesn't reflect the {@link Map} key granularity fallback logic that FreeMarker actually uses for this setting
      * (for that, use {@link #getCustomDateFormat(String)}). The returned value isn't a snapshot; it may or may not
-     * shows the changes later made to this setting on this {@link Configurable} level (but usually it's well defined if
+     * show the changes later made to this setting on this {@link Configurable} level (but usually it's well-defined if
      * until what point settings are possibly modified).
      * 
      * <p>
@@ -1805,7 +1820,11 @@ public class Configurable {
     }
 
     /**
-     * Gets the custom name format registered for the name.
+     * Gets the custom date format factory registered for the name, or {@code null} if no format with the given name
+     * was registered.
+     *
+     * @name The name of the custom format; do not start it with {@code '@'}!
+     * @return The format factory, or {@code null}
      * 
      * @since 2.3.24
      */
@@ -1819,15 +1838,83 @@ public class Configurable {
         }
         return parent != null ? parent.getCustomDateFormat(name) : null;
     }
+    
+    /**
+     * Getter pair of {@link #setCustomTemporalFormats(Map)}; do not modify the returned {@link Map}! To be consistent with
+     * other setting getters, if this setting was set directly on this {@link Configurable} object, this simply returns
+     * that value, otherwise it returns the value from the parent {@link Configurable}. So beware, the returned value
+     * doesn't reflect the {@link Map} key granularity fallback logic that FreeMarker actually uses for this setting
+     * (for that, use {@link #getCustomTemporalFormat(String)}). The returned value isn't a snapshot; it may or may not
+     * show the changes later made to this setting on this {@link Configurable} level (but usually it's well-defined if
+     * until what point settings are possibly modified).
+     *
+     * <p>
+     * The return value is never {@code null}; called on the {@link Configuration} (top) level, it defaults to an empty
+     * {@link Map}.
+     *
+     * @see #getCustomTemporalFormatsWithoutFallback()
+     *
+     * @since 2.3.32
+     */
+    public Map<String, ? extends TemplateTemporalFormatFactory> getCustomTemporalFormats() {
+        return customTemporalFormats == null ? parent.getCustomTemporalFormats() : customTemporalFormats;
+    }
 
     /**
-     * Gets the custom name format registered for the name.
+     * Like {@link #getCustomTemporalFormats()}, but doesn't fall back to the parent {@link Configurable}, nor does it
+     * provide a non-{@code null} default when called as the method of a {@link Configuration}.
      *
-     * @since 2.3.31
+     * @since 2.3.32
+     */
+    public Map<String, ? extends TemplateTemporalFormatFactory> getCustomTemporalFormatsWithoutFallback() {
+        return customTemporalFormats;
+    }
+
+    /**
+     * Associates names with formatter factories, which then can be referred by the various temporal format settings
+     * (like {@link #setLocalDateTimeFormat(String) local_date_time_format},
+     * {@link #setLocalDateFormat(String) local_date_format}, {@link #setLocalTimeFormat(String) local_time_format},
+     * and so on) a value starting with <code>@<i>name</i></code>.
+     *
+     * @param customTemporalFormats
+     *            Can't be {@code null}. The name must start with an UNICODE letter, and can only contain UNICODE
+     *            letters and digits.
+     *
+     * @since 2.3.32
+     */
+    public void setCustomTemporalFormats(Map<String, ? extends TemplateTemporalFormatFactory> customTemporalFormats) {
+        NullArgumentException.check("customTemporalFormats", customTemporalFormats);
+        validateFormatNames(customTemporalFormats.keySet());
+        this.customTemporalFormats = customTemporalFormats;
+    }
+
+    /**
+     * Tells if this setting is set directly in this object or its value is coming from the {@link #getParent() parent}.
+     *
+     * @since 2.3.32
+     */
+    public boolean isCustomTemporalFormatsSet() {
+        return this.customTemporalFormats != null;
+    }
+
+    /**
+     * Gets the custom temporal format factory registered for the name, or {@code null} if no format with the given name
+     * was registered.
+     *
+     * @name The name of the custom format; do not start it with {@code '@'}!
+     * @return The format factory or, {@code null}
+     *
+     * @since 2.3.32
      */
     public TemplateTemporalFormatFactory getCustomTemporalFormat(String name) {
-        // TODO [FREEMARKER-35]
-        return null;
+        TemplateTemporalFormatFactory r;
+        if (customTemporalFormats != null) {
+            r = customTemporalFormats.get(name);
+            if (r != null) {
+                return r;
+            }
+        }
+        return parent != null ? parent.getCustomTemporalFormat(name) : null;
     }
 
     /**
@@ -2677,7 +2764,12 @@ public class Configurable {
      *   <br>String value: Interpreted as an <a href="#fm_obe">object builder expression</a>.
      *   <br>Example: <code>{ "trade": com.example.TradeTemplateDateFormatFactory,
      *   "log": com.example.LogTemplateDateFormatFactory }</code>
-     *       
+     *
+     *   <li><p>{@code "custom_temporal_formats"}: See {@link #setCustomTemporalFormats(Map)}.
+     *   <br>String value: Interpreted as an <a href="#fm_obe">object builder expression</a>.
+     *   <br>Example: <code>{ "trade": com.example.TradeTemporalFormatFactory,
+     *   "log": com.example.LogTemporalFormatFactory }</code>
+     *
      *   <li><p>{@code "template_exception_handler"}:
      *       See {@link #setTemplateExceptionHandler(TemplateExceptionHandler)}.
      *       <br>String value: If the value contains dot, then it's interpreted as an <a href="#fm_obe">object builder
@@ -2705,7 +2797,7 @@ public class Configurable {
      *       If the value does not contain dot,
      *       then it must be one of these special values (case insensitive):
      *       {@code "bigdecimal"}, {@code "conservative"}.
-     *       
+     *
      *   <li><p>{@code "object_wrapper"}:
      *       See {@link #setObjectWrapper(ObjectWrapper)}.
      *       <br>String value: If the value contains dot, then it's interpreted as an <a href="#fm_obe">object builder
@@ -2724,12 +2816,27 @@ public class Configurable {
      *       {@code "jython"} (means {@link freemarker.ext.jython.JythonWrapper#DEFAULT_WRAPPER})
      *       
      *   <li><p>{@code "number_format"}: See {@link #setNumberFormat(String)}.
-     *   
+     *
      *   <li><p>{@code "boolean_format"}: See {@link #setBooleanFormat(String)} .
-     *   
+     *
      *   <li><p>{@code "date_format", "time_format", "datetime_format"}:
-     *       See {@link #setDateFormat(String)}, {@link #setTimeFormat(String)}, {@link #setDateTimeFormat(String)}. 
-     *        
+     *       See {@link #setDateFormat(String)}, {@link #setTimeFormat(String)}, {@link #setDateTimeFormat(String)}.
+     *
+     *   <li><p>{@code "local_date_format", "local_time_format", "local_datetime_format"}:
+     *       See {@link #setLocalDateFormat(String)}, {@link #setLocalTimeFormat(String)}, {@link #setLocalDateTimeFormat(String)}.
+     *
+     *   <li><p>{@code "offset_time_format", "offset_datetime_format"}:
+     *       See {@link #setOffsetTimeFormat(String)}, {@link #setOffsetDateTimeFormat(String)}.
+     *
+     *   <li><p>{@code "zoned_date_time_format"}:
+     *       See {@link #setZonedDateTimeFormat(String)}.
+     *
+     *   <li><p>{@code "year_format"}:
+     *       See {@link #setYearFormat(String)}.
+     *
+     *   <li><p>{@code "year_month_format"}:
+     *       See {@link #setYearMonthFormat(String)}.
+     *
      *   <li><p>{@code "time_zone"}:
      *       See {@link #setTimeZone(TimeZone)}.
      *       <br>String value: With the format as {@link TimeZone#getTimeZone} defines it. Also, since 2.3.21
@@ -3154,6 +3261,13 @@ public class Configurable {
                 _CoreAPI.checkSettingValueItemsType("Map keys", String.class, map.keySet());
                 _CoreAPI.checkSettingValueItemsType("Map values", TemplateDateFormatFactory.class, map.values());
                 setCustomDateFormats(map);
+            } else if (CUSTOM_TEMPORAL_FORMATS_KEY_SNAKE_CASE.equals(name)
+                    || CUSTOM_TEMPORAL_FORMATS_KEY_CAMEL_CASE.equals(name)) {
+                Map map = (Map) _ObjectBuilderSettingEvaluator.eval(
+                                value, Map.class, false, _SettingEvaluationEnvironment.getCurrent());
+                _CoreAPI.checkSettingValueItemsType("Map keys", String.class, map.keySet());
+                _CoreAPI.checkSettingValueItemsType("Map values", TemplateTemporalFormatFactory.class, map.values());
+                setCustomTemporalFormats(map);
             } else if (TIME_ZONE_KEY_SNAKE_CASE.equals(name) || TIME_ZONE_KEY_CAMEL_CASE.equals(name)) {
                 setTimeZone(parseTimeZoneSettingValue(value));
             } else if (SQL_DATE_AND_TIME_TIME_ZONE_KEY_SNAKE_CASE.equals(name)
diff --git a/src/main/java/freemarker/core/TemplateConfiguration.java b/src/main/java/freemarker/core/TemplateConfiguration.java
index cd8410f..7e41d7f 100644
--- a/src/main/java/freemarker/core/TemplateConfiguration.java
+++ b/src/main/java/freemarker/core/TemplateConfiguration.java
@@ -181,6 +181,9 @@ public final class TemplateConfiguration extends Configurable implements ParserC
         if (tc.isCustomDateFormatsSet()) {
             setCustomDateFormats(mergeMaps(getCustomDateFormats(), tc.getCustomDateFormats(), false));
         }
+        if (tc.isCustomTemporalFormatsSet()) {
+            setCustomTemporalFormats(mergeMaps(getCustomTemporalFormats(), tc.getCustomTemporalFormats(), false));
+        }
         if (tc.isCustomNumberFormatsSet()) {
             setCustomNumberFormats(mergeMaps(getCustomNumberFormats(), tc.getCustomNumberFormats(), false));
         }
@@ -349,6 +352,10 @@ public final class TemplateConfiguration extends Configurable implements ParserC
             template.setCustomDateFormats(
                     mergeMaps(getCustomDateFormats(), template.getCustomDateFormatsWithoutFallback(), false));
         }
+        if (isCustomTemporalFormatsSet()) {
+            template.setCustomTemporalFormats(
+                    mergeMaps(getCustomTemporalFormats(), template.getCustomTemporalFormatsWithoutFallback(), false));
+        }
         if (isCustomNumberFormatsSet()) {
             template.setCustomNumberFormats(
                     mergeMaps(getCustomNumberFormats(), template.getCustomNumberFormatsWithoutFallback(), false));
@@ -730,6 +737,7 @@ public final class TemplateConfiguration extends Configurable implements ParserC
                 || isBooleanFormatSet()
                 || isClassicCompatibleSet()
                 || isCustomDateFormatsSet()
+                || isCustomTemporalFormatsSet()
                 || isCustomNumberFormatsSet()
                 || isDateFormatSet()
                 || isDateTimeFormatSet()
diff --git a/src/main/java/freemarker/core/TemplateFormatUtil.java b/src/main/java/freemarker/core/TemplateFormatUtil.java
index 76acfb0..4dd8ab9 100644
--- a/src/main/java/freemarker/core/TemplateFormatUtil.java
+++ b/src/main/java/freemarker/core/TemplateFormatUtil.java
@@ -65,7 +65,7 @@ public final class TemplateFormatUtil {
      * Utility method to extract the {@link Date} from an {@link TemplateDateModel}, and throw
      * {@link TemplateModelException} with a standard error message if that's {@code null}. {@link TemplateDateModel}
      * that store {@code null} are in principle not allowed, and so are considered to be bugs in the
-     * {@link ObjectWrapper} or {@link TemplateNumberModel} implementation.
+     * {@link ObjectWrapper} or {@link TemplateDateModel} implementation.
      */
     public static Date getNonNullDate(TemplateDateModel dateModel) throws TemplateModelException {
         Date date = dateModel.getAsDate();
@@ -75,12 +75,20 @@ public final class TemplateFormatUtil {
         return date;
     }
 
+    /**
+     * Utility method to extract the {@link Temporal} from an {@link TemplateTemporalModel}, and throw
+     * {@link TemplateModelException} with a standard error message if that's {@code null}. {@link TemplateTemporalModel}
+     * that store {@code null} are in principle not allowed, and so are considered to be bugs in the
+     * {@link ObjectWrapper} or {@link TemplateTemporalModel} implementation.
+     *
+     * @since 2.3.32
+     */
     public static Temporal getNonNullTemporal(TemplateTemporalModel temporalModel) throws TemplateModelException {
-        Temporal date = temporalModel.getAsTemporal();
-        if (date == null) {
-            throw EvalUtil.newModelHasStoredNullException(Date.class, temporalModel, null);
+        Temporal temporal = temporalModel.getAsTemporal();
+        if (temporal == null) {
+            throw EvalUtil.newModelHasStoredNullException(Temporal.class, temporalModel, null);
         }
-        return date;
+        return temporal;
     }
 
 }
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java b/src/main/java/freemarker/core/TemplateTemporalFormat.java
index 7b43265..bf0602f 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/TemplateTemporalFormat.java
@@ -50,7 +50,7 @@ public abstract class TemplateTemporalFormat extends TemplateValueFormat {
      *
      * @return A {@link String} or a {@link TemplateMarkupOutputModel}; not {@code null}.
      */
-    public String format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, TemplateModelException {
+    public Object format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, TemplateModelException {
         return formatToPlainText(temporalModel);
     }
 
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java
index 885ea7b..5ab995b 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java
+++ b/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java
@@ -27,8 +27,8 @@ import freemarker.template.Configuration;
 /**
  * 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)
+ *
+ * @see Configurable#setCustomTemporalFormats(java.util.Map)
  * 
  * @since 2.3.32
  */
diff --git a/src/test/java/freemarker/core/BaseNTemplateNumberFormatFactory.java b/src/test/java/freemarker/core/BaseNTemplateNumberFormatFactory.java
index acc81ef..c217931 100644
--- a/src/test/java/freemarker/core/BaseNTemplateNumberFormatFactory.java
+++ b/src/test/java/freemarker/core/BaseNTemplateNumberFormatFactory.java
@@ -70,7 +70,7 @@ public class BaseNTemplateNumberFormatFactory extends TemplateNumberFormatFactor
                         "A format parameter is required to specify the numerical system base.");
             }
             throw new InvalidFormatParametersException(
-                    "The format paramter must be an integer, but was (shown quoted): "
+                    "The format parameter must be an integer, but was (shown quoted): "
                     + StringUtil.jQuote(params));
         }
         if (base < 2) {
diff --git a/src/test/java/freemarker/core/EpochMillisDivTemplateDateFormatFactory.java b/src/test/java/freemarker/core/EpochMillisDivTemplateDateFormatFactory.java
index 00f56f3..23c0199 100644
--- a/src/test/java/freemarker/core/EpochMillisDivTemplateDateFormatFactory.java
+++ b/src/test/java/freemarker/core/EpochMillisDivTemplateDateFormatFactory.java
@@ -46,7 +46,7 @@ public class EpochMillisDivTemplateDateFormatFactory extends TemplateDateFormatF
                         "A format parameter is required, which specifies the divisor.");
             }
             throw new InvalidFormatParametersException(
-                    "The format paramter must be an integer, but was (shown quoted): " + StringUtil.jQuote(params));
+                    "The format parameter must be an integer, but was (shown quoted): " + StringUtil.jQuote(params));
         }
         return new EpochMillisDivTemplateDateFormat(divisor);
     }
diff --git a/src/test/java/freemarker/core/EpochMillisDivTemplateDateFormatFactory.java b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
similarity index 57%
copy from src/test/java/freemarker/core/EpochMillisDivTemplateDateFormatFactory.java
copy to src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
index 00f56f3..ff5ba63 100644
--- a/src/test/java/freemarker/core/EpochMillisDivTemplateDateFormatFactory.java
+++ b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
@@ -18,25 +18,30 @@
  */
 package freemarker.core;
 
-import java.util.Date;
+import java.time.Instant;
+import java.time.temporal.Temporal;
 import java.util.Locale;
 import java.util.TimeZone;
 
-import freemarker.template.TemplateDateModel;
 import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateTemporalModel;
 import freemarker.template.utility.StringUtil;
 
-public class EpochMillisDivTemplateDateFormatFactory extends TemplateDateFormatFactory {
+public class EpochMillisDivTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
 
-    public static final EpochMillisDivTemplateDateFormatFactory INSTANCE = new EpochMillisDivTemplateDateFormatFactory();
+    public static final EpochMillisDivTemplateTemporalFormatFactory
+            INSTANCE = new EpochMillisDivTemplateTemporalFormatFactory();
     
-    private EpochMillisDivTemplateDateFormatFactory() {
+    private EpochMillisDivTemplateTemporalFormatFactory() {
         // Defined to decrease visibility
     }
     
     @Override
-    public TemplateDateFormat get(String params, int dateType, Locale locale, TimeZone timeZone, boolean zonelessInput,
-            Environment env) throws UnknownDateTypeFormattingUnsupportedException, InvalidFormatParametersException {
+    public TemplateTemporalFormat get(
+            String params,
+            Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone,
+            Environment env)
+            throws TemplateValueFormatException {
         int divisor;
         try {
             divisor = Integer.parseInt(params);
@@ -46,23 +51,30 @@ public class EpochMillisDivTemplateDateFormatFactory extends TemplateDateFormatF
                         "A format parameter is required, which specifies the divisor.");
             }
             throw new InvalidFormatParametersException(
-                    "The format paramter must be an integer, but was (shown quoted): " + StringUtil.jQuote(params));
+                    "The format parameter must be an integer, but was (shown quoted): " + StringUtil.jQuote(params));
         }
-        return new EpochMillisDivTemplateDateFormat(divisor);
+        return new EpochMillisDivTemplateTemporalFormat(divisor);
     }
 
-    private static class EpochMillisDivTemplateDateFormat extends TemplateDateFormat {
+    private static class EpochMillisDivTemplateTemporalFormat extends TemplateTemporalFormat {
 
         private final int divisor;
         
-        private EpochMillisDivTemplateDateFormat(int divisor) {
+        private EpochMillisDivTemplateTemporalFormat(int divisor) {
             this.divisor = divisor;
         }
         
         @Override
-        public String formatToPlainText(TemplateDateModel dateModel)
+        public String formatToPlainText(TemplateTemporalModel temporalModel)
                 throws UnformattableValueException, TemplateModelException {
-            return String.valueOf(TemplateFormatUtil.getNonNullDate(dateModel).getTime() / divisor);
+            Temporal temporal = TemplateFormatUtil.getNonNullTemporal(temporalModel);
+            long epochMillis;
+            try {
+                epochMillis = temporal.query(Instant::from).toEpochMilli();
+            } catch (Exception e) {
+                throw new UnformattableValueException("Can't extract epoch millis from " + temporal.getClass().getName() + " object.");
+            }
+            return String.valueOf(epochMillis / divisor);
         }
 
         @Override
@@ -76,15 +88,6 @@ public class EpochMillisDivTemplateDateFormatFactory extends TemplateDateFormatF
         }
 
         @Override
-        public Date parse(String s, int dateType) throws UnparsableValueException {
-            try {
-                return new Date(Long.parseLong(s));
-            } catch (NumberFormatException e) {
-                throw new UnparsableValueException("Malformed long");
-            }
-        }
-
-        @Override
         public String getDescription() {
             return "millis since the epoch";
         }
diff --git a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..a51b6ad
--- /dev/null
+++ b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
@@ -0,0 +1,83 @@
+/*
+ * 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.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateTemporalModel;
+
+public class EpochMillisTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
+
+    public static final EpochMillisTemplateTemporalFormatFactory INSTANCE
+            = new EpochMillisTemplateTemporalFormatFactory();
+
+    private EpochMillisTemplateTemporalFormatFactory() {
+        // Defined to decrease visibility
+    }
+    
+    @Override
+    public TemplateTemporalFormat get(
+            String params, Class<? extends Temporal> temporalClass,
+            Locale locale, TimeZone timeZone, Environment env)
+            throws InvalidFormatParametersException {
+        TemplateFormatUtil.checkHasNoParameters(params);
+        return EpochMillisTemplateTemporalFormat.INSTANCE;
+    }
+
+    private static class EpochMillisTemplateTemporalFormat extends TemplateTemporalFormat {
+
+        private static final EpochMillisTemplateTemporalFormat INSTANCE = new EpochMillisTemplateTemporalFormat();
+        
+        private EpochMillisTemplateTemporalFormat() { }
+        
+        @Override
+        public String formatToPlainText(TemplateTemporalModel temporalModel)
+                throws UnformattableValueException, TemplateModelException {
+            Temporal temporal = TemplateFormatUtil.getNonNullTemporal(temporalModel);
+            long epochMillis;
+            try {
+                epochMillis = temporal.query(Instant::from).toEpochMilli();
+            } catch (Exception e) {
+                throw new UnformattableValueException("Can't extract epoch millis from " + temporal.getClass().getName() + " object.");
+            }
+            return String.valueOf(epochMillis);
+        }
+
+        @Override
+        public boolean isLocaleBound() {
+            return false;
+        }
+
+        @Override
+        public boolean isTimeZoneBound() {
+            return false;
+        }
+
+        @Override
+        public String getDescription() {
+            return "millis since the epoch";
+        }
+        
+    }
+
+}
diff --git a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..5226fd2
--- /dev/null
+++ b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
@@ -0,0 +1,86 @@
+/*
+ * 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;
+
+import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateTemporalModel;
+
+public class HTMLISOTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
+
+    public static final HTMLISOTemplateTemporalFormatFactory INSTANCE = new HTMLISOTemplateTemporalFormatFactory();
+
+    private HTMLISOTemplateTemporalFormatFactory() {
+        // Defined to decrease visibility
+    }
+
+    @Override
+    public TemplateTemporalFormat get(
+            String params,
+            Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone,
+            Environment env)
+            throws TemplateValueFormatException {
+        TemplateFormatUtil.checkHasNoParameters(params);
+        TemplateTemporalFormat isoFormat = ISOTemplateTemporalFormatFactory.INSTANCE
+                .get("", temporalClass, locale, timeZone, env);
+        return new HTMLISOTemplateTemporalFormat(isoFormat);
+    }
+
+    private static class HTMLISOTemplateTemporalFormat extends TemplateTemporalFormat {
+
+        private final TemplateTemporalFormat isoFormat;
+
+        private HTMLISOTemplateTemporalFormat(TemplateTemporalFormat isoFormat) {
+            this.isoFormat = isoFormat;
+        }
+        
+        @Override
+        public String formatToPlainText(TemplateTemporalModel temporalModel)
+                throws TemplateValueFormatException, TemplateModelException {
+            return isoFormat.formatToPlainText(temporalModel);
+        }
+
+        @Override
+        public boolean isLocaleBound() {
+            return false;
+        }
+
+        @Override
+        public boolean isTimeZoneBound() {
+            return false;
+        }
+
+        @Override
+        public Object format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, TemplateModelException {
+            return HTMLOutputFormat.INSTANCE.fromMarkup(
+                    formatToPlainText(temporalModel).replace("T", "<span class='T'>T</span>"));
+        }
+
+        @Override
+        public String getDescription() {
+            return "ISO UTC HTML";
+        }
+        
+    }
+
+}
+ 
\ No newline at end of file
diff --git a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateDateFormatFactory.java b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateDateFormatFactory.java
index ea2b6f7..51cbe94 100644
--- a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateDateFormatFactory.java
+++ b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateDateFormatFactory.java
@@ -53,7 +53,7 @@ public class LocAndTZSensitiveTemplateDateFormatFactory extends TemplateDateForm
         @Override
         public String formatToPlainText(TemplateDateModel dateModel)
                 throws UnformattableValueException, TemplateModelException {
-            return String.valueOf(TemplateFormatUtil.getNonNullDate(dateModel).getTime() + "@" + locale + ":" + timeZone.getID());
+            return TemplateFormatUtil.getNonNullDate(dateModel).getTime() + "@" + locale + ":" + timeZone.getID();
         }
 
         @Override
diff --git a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateDateFormatFactory.java b/src/test/java/freemarker/core/LocAndTZSensitiveTemporalFormatFactory.java
similarity index 53%
copy from src/test/java/freemarker/core/LocAndTZSensitiveTemplateDateFormatFactory.java
copy to src/test/java/freemarker/core/LocAndTZSensitiveTemporalFormatFactory.java
index ea2b6f7..4b7b46a 100644
--- a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateDateFormatFactory.java
+++ b/src/test/java/freemarker/core/LocAndTZSensitiveTemporalFormatFactory.java
@@ -18,42 +18,53 @@
  */
 package freemarker.core;
 
-import java.util.Date;
+import java.time.Instant;
+import java.time.temporal.Temporal;
 import java.util.Locale;
 import java.util.TimeZone;
 
-import freemarker.template.TemplateDateModel;
 import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateTemporalModel;
 
-public class LocAndTZSensitiveTemplateDateFormatFactory extends TemplateDateFormatFactory {
+public class LocAndTZSensitiveTemporalFormatFactory extends TemplateTemporalFormatFactory {
 
-    public static final LocAndTZSensitiveTemplateDateFormatFactory INSTANCE = new LocAndTZSensitiveTemplateDateFormatFactory();
+    public static final LocAndTZSensitiveTemporalFormatFactory INSTANCE = new LocAndTZSensitiveTemporalFormatFactory();
     
-    private LocAndTZSensitiveTemplateDateFormatFactory() {
+    private LocAndTZSensitiveTemporalFormatFactory() {
         // Defined to decrease visibility
     }
     
     @Override
-    public TemplateDateFormat get(String params, int dateType, Locale locale, TimeZone timeZone, boolean zonelessInput,
-            Environment env) throws UnknownDateTypeFormattingUnsupportedException, InvalidFormatParametersException {
+    public TemplateTemporalFormat get(
+            String params, Class<? extends Temporal> temporalClass,
+            Locale locale, TimeZone timeZone,
+            Environment env)
+            throws UnknownDateTypeFormattingUnsupportedException, InvalidFormatParametersException {
         TemplateFormatUtil.checkHasNoParameters(params);
-        return new LocAndTZSensitiveTemplateDateFormat(locale, timeZone);
+        return new LocAndTZSensitiveTemplateTemporalFormat(locale, timeZone);
     }
 
-    private static class LocAndTZSensitiveTemplateDateFormat extends TemplateDateFormat {
+    private static class LocAndTZSensitiveTemplateTemporalFormat extends TemplateTemporalFormat {
 
         private final Locale locale;
         private final TimeZone timeZone;
         
-        public LocAndTZSensitiveTemplateDateFormat(Locale locale, TimeZone timeZone) {
+        public LocAndTZSensitiveTemplateTemporalFormat(Locale locale, TimeZone timeZone) {
             this.locale = locale;
             this.timeZone = timeZone;
         }
 
         @Override
-        public String formatToPlainText(TemplateDateModel dateModel)
+        public String formatToPlainText(TemplateTemporalModel temporalModel)
                 throws UnformattableValueException, TemplateModelException {
-            return String.valueOf(TemplateFormatUtil.getNonNullDate(dateModel).getTime() + "@" + locale + ":" + timeZone.getID());
+            Temporal temporal = TemplateFormatUtil.getNonNullTemporal(temporalModel);
+            long epochMillis;
+            try {
+                epochMillis = temporal.query(Instant::from).toEpochMilli();
+            } catch (Exception e) {
+                throw new UnformattableValueException("Can't extract epoch millis from " + temporal.getClass().getName() + " object.");
+            }
+            return epochMillis + "@" + locale + ":" + timeZone.getID();
         }
 
         @Override
@@ -67,19 +78,6 @@ public class LocAndTZSensitiveTemplateDateFormatFactory extends TemplateDateForm
         }
 
         @Override
-        public Date parse(String s, int dateType) throws UnparsableValueException {
-            try {
-                int atIdx = s.indexOf("@");
-                if (atIdx == -1) {
-                    throw new UnparsableValueException("Missing @");
-                }
-                return new Date(Long.parseLong(s.substring(0, atIdx)));
-            } catch (NumberFormatException e) {
-                throw new UnparsableValueException("Malformed long");
-            }
-        }
-
-        @Override
         public String getDescription() {
             return "millis since the epoch";
         }
diff --git a/src/test/java/freemarker/core/TemplateConfigurationTest.java b/src/test/java/freemarker/core/TemplateConfigurationTest.java
index 57ed326..c5aa08b 100644
--- a/src/test/java/freemarker/core/TemplateConfigurationTest.java
+++ b/src/test/java/freemarker/core/TemplateConfigurationTest.java
@@ -190,6 +190,8 @@ public class TemplateConfigurationTest {
                 ImmutableMap.of("dummy", HexTemplateNumberFormatFactory.INSTANCE));
         SETTING_ASSIGNMENTS.put("customDateFormats",
                 ImmutableMap.of("dummy", EpochMillisTemplateDateFormatFactory.INSTANCE));
+        SETTING_ASSIGNMENTS.put("customTemporalFormats",
+                ImmutableMap.of("dummy", EpochMillisTemplateTemporalFormatFactory.INSTANCE));
         SETTING_ASSIGNMENTS.put("truncateBuiltinAlgorithm", DefaultTruncateBuiltinAlgorithm.UNICODE_INSTANCE);
 
         // Parser-only settings:
diff --git a/src/test/java/freemarker/core/TemporalFormatTest2.java b/src/test/java/freemarker/core/TemporalFormatTest2.java
new file mode 100644
index 0000000..215eb7c
--- /dev/null
+++ b/src/test/java/freemarker/core/TemporalFormatTest2.java
@@ -0,0 +1,76 @@
+/*
+ * 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.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import com.google.common.collect.ImmutableMap;
+
+import freemarker.template.Configuration;
+import freemarker.test.TemplateTest;
+
+/**
+ * Like {@link TemporalFormatTest}, but this one contains the tests that utilize {@link TemplateTest}.
+ */
+public class TemporalFormatTest2 extends TemplateTest {
+
+    @Before
+    public void setup() {
+        Configuration cfg = getConfiguration();
+        cfg.setIncompatibleImprovements(Configuration.VERSION_2_3_32);
+        cfg.setLocale(Locale.US);
+        cfg.setTimeZone(TimeZone.getTimeZone("GMT+01:00"));
+
+        cfg.setCustomTemporalFormats(ImmutableMap.of(
+                "epoch", EpochMillisTemplateTemporalFormatFactory.INSTANCE,
+                "loc", LocAndTZSensitiveTemporalFormatFactory.INSTANCE,
+                "div", EpochMillisDivTemplateTemporalFormatFactory.INSTANCE,
+                "htmlIso", HTMLISOTemplateTemporalFormatFactory.INSTANCE));
+    }
+
+    @Test
+    public void testCustomFormat() throws Exception {
+        addToDataModel("d", OffsetDateTime.of(
+                1970, 1, 2,
+                10, 17, 36, 789000000,
+                ZoneOffset.ofHours(0)));
+        assertOutput(
+                "${d?string.@epoch} ${d?string.@epoch} <#setting locale='de_DE'>${d?string.@epoch}",
+                "123456789 123456789 123456789");
+
+        getConfiguration().setOffsetDateTimeFormat("@epoch");
+        assertOutput(
+                "${d} ${d?string} <#setting locale='de_DE'>${d}",
+                "123456789 123456789 123456789");
+
+        getConfiguration().setOffsetDateTimeFormat("@htmlIso");
+        assertOutput(
+                "${d} ${d?string} <#setting locale='de_DE'>${d}",
+                "1970-01-02<span class='T'>T</span>10:17:36.789Z "
+                        + "1970-01-02T10:17:36.789Z "
+                        + "1970-01-02<span class='T'>T</span>10:17:36.789Z");
+    }
+}