FreshMarker Fo(u)r Seasons

“In the depth of winter, I finally learned that within me there lay an invincible summer.”

― Albert Camus

Some features in open source projects are gimmicks whose utility value is rather low. On the other hand, they enable the evaluation of new functions and previously unused libraries. In this article, support for astronomical seasons is implemented in Freshmarker using the Time4J library.

FreshMarker has a helpful extension mechanism that can be used to supplement the core functionality of the template engine. New primitive FreshMarker types, new feature flags, new built-ins and new built-in variables can be added.

Time4J is a Java library designed for advanced date, time and interval calculations. It is intended as a modern replacement for older Java classes such as java.util.Date and java.util.Calendar. It is also intended as an alternative to Joda-Time and JSR-310. For this blog post, we will only look at the net.time4j.calendar.astro.AstronomicalSeason class.

To support this data type in FreshMarker as a primitive type, we need a class that implements some FreshMarker Extension interfaces.

public class AstroExtension implements BuiltInProvider, BuiltInVariableProvider, TemplateFeatureProvider, TypeMapperProvider {
  // ...
}

The AstroExtension implements four interfaces: BuiltInProvider, BuiltInVariableProvider, TemplateFeatureProvider and TypeMapperProvider. What the interfaces are used for is discussed below.

No additional work is actually required to use an AstronomicalSeason instance in the model. As an enum, this type is automatically recognized by FreshMarker in the model. However, no additional built-ins can then be added. A TypeMapperProvider is required so that a custom primitive type can be used.

@Override
public Map<Class<?>, TypeMapper> providerTypeMapper() {
  return Map.of(AstronomicalSeason.class, o -> new TemplateSeason((AstronomicalSeason)o));
}

The providerTypeMapper method returns a Map that contains a TypeMapper for each recognized type. In this case, only for the AstronomicalSeason class, with a TypeMapper that creates an instance of the TemplateSeason class.

public class TemplateSeason extends TemplatePrimitive<AstronomicalSeason> {
  private static final Map<AstronomicalSeason, TemplateSeason> VALUES = Arrays.stream(AstronomicalSeason.values())
        .collect(Collectors.toMap(Function.identity(), TemplateSeason::new));

  public static TemplateSeason of(AstronomicalSeason season) {
    return VALUES.get(season);
  }

  private TemplateSeason(AstronomicalSeason value) {
    super(value);
  }
}

The TemplateSeason class inherits from TemplatePrimitive and that’s all you need to know about the implementation. The custom type is necessary to implement built-ins for the AstronomicalSeason class, so let’s look at the implementation of the BuiltInProvider interface.

@Override
public Register<Class<? extends TemplateObject>, String, BuiltIn> provideBuiltInRegister() {
  BuiltInRegister register = new BuiltInRegister();
  register.add(TemplateSeason.class, "c", (x, y, c) -> new TemplateString(((TemplateSeason) x).getValue().toString()));
  register.add(TemplateSeason.class, "h", (x, y, c) -> getSeasonName((TemplateSeason) x, c));
  register.add(TemplateSeason.class, "next", (x, y, c) -> getAdjacentSeason((TemplateSeason) x, 1));
  register.add(TemplateSeason.class, "previous", (x, y, c) -> getAdjacentSeason((TemplateSeason) x, -1));
  register.add(TemplateZonedDateTime.class, "season", (x, y, c) -> getAstronomicalSeason(((TemplateZonedDateTime) x).getValue().toInstant(), c));
  register.add(TemplateInstant.class, "season", (x, y, c) -> getAstronomicalSeason(((TemplateInstant) x).getValue(), c));
  return register;
}

In the provideBuiltInRegister method, BuiltIn implementations are assigned to various types. We give the TemplateSeason class four BuiltIn implementations. These are c, h, next and previous.

The BuiltIn implementation c (computer language) generates the String representation of the enum value as output. This means that the Built-In expression ${season?c} generates the same output as the expression ${season}. The Built-In is helpful if different types are permitted in an expression, some of which require a Built-In c.

The BuiltIn implementation h (human language) generates an I18N representation of the enum value. This means that the words Frühling, Sommer, Herbst and Winter are used in German and spring, summer, autumn and winter in English.

private static TemplateString getSeasonName(TemplateSeason x, ProcessContext c) {
  return new TemplateString(ResourceBundle.getBundle("astro", c.getLocale()).getString("season." + x.getValue()));
}

The getSeasonName method retrieves the corresponding name from the ResourceBundle astro for the current locale and returns it as a TemplateString.

The BuiltIn implementations previous and next provide the previous season and the following season respectively. They are both implemented by the getAdjacentSeason method.

private TemplateSeason getAdjacentSeason(TemplateSeason x, int offset) {
  AstronomicalSeason season = x.getValue();
  return TemplateSeason.of(AstronomicalSeason.values()[(season.ordinal() + (offset < 0 ? offset + 4 : offset) % 4]);
}

Here, the appropriate enum constant is selected with the help of the modulo operator and the corresponding AstronomicalSeason object is returned.

The BuiltIn implementations season allows you to calculate an AstronomicalSeason from a ZonedDateTime or an Instant instanz. At this point, things get a little more complicated.

My birthday falls in summer, so a ZonedDateTime variable with my birthday in the expression ${birthday?season?h} should return the value summer. But this is, in the truest sense of the word, only half the truth. Because my birthday is only in summer in the northern hemisphere. In the southern hemisphere, my birthday falls in winter. So for a proper implementation, we need to know for which hemisphere the calculation is being carried out.

Unfortunately, there is no reference to the hemishere in FreshMarker or Java so far, so we’ll make do with a solution using the on-board tools of the Extension API. For use in the southern hemisphere, we provide the feature AstroFeatures.AT_SOUTHERN_HEMISPHERE.

Configuration configuration = new Configuration(AstroFeatures.AT_SOUTHERN_HEMISPHERE);

This configuration ensures that the calculations are carried out for the southern hemisphere. As a TemplateFeatureProvider, our extension registers the feature with FreshMarker.

@Override
public Set<TemplateFeature> provideFeatures() {
  return Set.of(AstroFeatures.values());
}

To generate an AstronomicalSeason from a Temporal, the getAstronomicalSeason method is called, which allows the calculated AstronomicalSeason value to be manipulated again by the byHemishere method.

private TemplateSeason getAstronomicalSeason(Instant x, ProcessContext c) {
  return new TemplateSeason(byHemisphere(AstronomicalSeason.of(Moment.from(x)),c ));
}

private AstronomicalSeason byHemisphere(AstronomicalSeason season, ProcessContext context) {
  if (computeHemisphere(context) == Hemisphere.NORTH) {
    return season;
  }
  return season.onSouthernHemisphere();
}

The byHemishere method uses the computeHemisphere method to check whether the AstronomicalSeason needs to be changed. If the result of computeHemisphere is Hemisphere.NORTH, then the value remains unchanged, otherwise it is changed.

private static Hemisphere computeHemisphere(ProcessContext context) {
  return context.getFeatureSet().isEnabled(AstroFeatures.AT_SOUTHERN_HEMISPHERE) ? Hemisphere.SOUTH : Hemisphere.NORTH;
}

The computeHemisphere method is the reason why the new Extension cannot be used with FreshMarker 1.10.0, but you have to wait for version 1.11.0. The FreshMarker features are not yet accessible via the ProcessContext.

Anyone who remembers the interfaces from the first code example will miss the BuiltInVariableProvider interface. This interface can be used to integrate your own BuiltInVariables into FreshMarker. Since we can now configure the hemisphere, it would also be nice if we could also use it in our expressions. We provide a separate Built-In Variable .hemisphere for this purpose.

@Override
public Map<String, BuiltInVariable> provideBuiltInVariables() {
  return Map.of("hemisphere", context -> new TemplateEnum<>(computeHemisphere(context)));
}

Sie berechnet eine TemplateEnum Instanz basierend auf dem Ergebnis unserer computeHemisphere Methode.

The following example can be processed using the implementation discussed here. Seasons can be generated from dates and manipulated further. It is also possible to specify the hemisphere for season calculations.

${season?h}
${season?next?h}
${season?next?next?h}
${season?previous?h}
${day?season?h}
${.hemisphere}
Template template = new Configuration(AT_SOUTHERN_HEMISPHERE)
  .builder().withLocale(Locale.GERMANY).withZoneId(CET)
  .get("example", contentFromLeftColumn);
Map<String, Object> model = Map.of(
  "season", AstronomicalSeason.VERNAL_EQUINOX,
  "date", myBirthday.atZone(CET)
);
String contentOfRightColumn = template.process(model);
   
Herbst
Winter
Frühling
Sommer
Winter
SOUTH

We have now completed our Extension and discovered and fixed a weakness in the former Extension API. If you want to use the new Extension, you will have to be patient until FreshMarker 1.11.0 is available.

Leave a Comment