Nested Directives with Default Content

Macro-based templates often need a sensible default body for the common case, while still allowing customization at the call site. FreshMarker 3.0.0 will support exactly that: <#nested/> can carry inline fallback content that renders automatically when no caller body is provided.

This article explains how the feature is implemented, from grammar to runtime, and includes a design note on one carefully chosen trade-off.

What the Feature Looks Like

Given this macro definition:

<#macro copyright from author="Jens Kaiser">
/*
 * Copyright © ${from}-${.now?string('yyyy')} ${author}
 *
 * <#nested>All Rights reserved.</#nested>
 */
</#macro>

Calling it without a body produces the fallback text:

<@copyright from="1968"/>
/*
 * Copyright © 1968-2026 Jens Kaiser
 *
 * All Rights reserved.
 */

Calling it with an explicit body overrides the default:

<@copyright from="1968">Licensed under the Apache License, Version 2.0</@copyright>
/*
 * Copyright © 1968-2026 Jens Kaiser
 *
 * Licensed under the Apache License, Version 2.0
 */

Without the fallback content in the nested directive, the template developer would have to either always specify the nested content, write multiple copyright macros (copyright, copyright_apache), or add a flag to the copyright macro and select different texts.

Grammar: nested Can Now Carry a Block

The grammar rule for NestedInstruction was extended to allow an optional inline body in addition to the existing self-closing form:

Nested #NestedInstruction :
    (<FTL_DIRECTIVE_OPEN1>|<FTL_DIRECTIVE_OPEN2>)
    <NESTED>
    [<BLANK> PositionalArgsList]
    ( <CLOSE_EMPTY_TAG>
    |
      <CLOSE_TAG>
      [
        SCAN { !isLooseEndTagMode() } => Block DirectiveEnd("nested")
      ]
    )
;

Two notable points:

  1. Positional arguments and default block are independent: they can appear together or separately.
  2. Block-style nested is disabled in LOOSE_END_MODE by design (a parse exception is thrown). In loose mode, tag boundaries are relaxed and a nested body would be ambiguous — so it is explicitly excluded.

Build Phase: Storing the Default Fragment

FragmentBuilder.visit(NestedInstruction ...) now determines at build time whether a default block is present and passes it into the fragment:

@Override
public List<Fragment> visit(NestedInstruction ftl, List<Fragment> input) {
  if (ftl.size() < 4) {
    input.add(NESTED_INSTRUCTION_FRAGMENT);
    return input;
  }

  List<TemplateObject> parameters;
  int index;
  if (ftl.get(2).getType() == TokenType.BLANK) {
    index = 5;
    parameters = ftl.get(3).accept(new PositionalArgsListBuilder(interpolationBuilder), new ArrayList<>());
  } else {
    index = 3;
    parameters = List.of();
  }

  if (ftl.size() > index && ftl.get(index).getType() != TokenType.CLOSE_TAG) {
    Fragment nestedDefault = Fragments.optimize(ftl.get(index).accept(this, new ArrayList<>()), false);
    input.add(new NestedInstructionFragment(parameters, nestedDefault));
  } else {
    input.add(new NestedInstructionFragment(parameters, ConstantFragment.EMPTY));
  }
  return input;
}

The result is that NestedInstructionFragment always holds two things: the positional argument expressions and the default fragment (or ConstantFragment.EMPTY as a no-op sentinel).

The static constant NESTED_INSTRUCTION_FRAGMENT (for the most common bare <#nested/> case) is now also created with ConstantFragment.EMPTY as fallback:

private static final NestedInstructionFragment NESTED_INSTRUCTION_FRAGMENT =
    new NestedInstructionFragment(List.of(), ConstantFragment.EMPTY);

Runtime Decision: Caller Body First, Default as Fallback

At render time, NestedInstructionFragment.process(...) decides which content to render:

@Override
public void process(ProcessContext context) {
  Environment oldEnvironment = context.getEnvironment();
  try {
    Optional<NestedContextHolder> nestedContent = oldEnvironment.getNestedContent();
    if (nestedContent.map(NestedContextHolder::fragment).orElse(ConstantFragment.EMPTY) != ConstantFragment.EMPTY) {
      nestedContent.ifPresent(n -> {
        Environment environment = new NestedInstructionEnvironment(parameters, n.nestedIdentifier(), oldEnvironment, context);
        context.setEnvironment(environment);
        n.fragment().process(context);
      });
    } else {
      nestedDefault.process(context);
    }
  } finally {
    context.setEnvironment(oldEnvironment);
  }
}

The logic is a clean if/else:

  • Caller body present → execute caller body inside a NestedInstructionEnvironment (with parameter binding)
  • Caller body absent → execute nestedDefault directly

In both cases, the original environment is restored via try/finally.

The identity check != ConstantFragment.EMPTY is intentional: ConstantFragment.EMPTY is used as a well-known sentinel that signals no content given, not as a runtime rendering decision.

How Nested Content Is Carried Through the Environment

The Environment interface returns nested execution context as a NestedContextHolder:

public record NestedContextHolder(Fragment fragment, List<ParameterHolder> nestedIdentifier) { }

MacroEnvironment fills it when a macro is being executed:

@Override
public Optional<NestedContextHolder> getNestedContent() {
  return Optional.of(new NestedContextHolder(body, nestedIdentifier));
}

f no macro is active in the call chain, BaseEnvironment.getNestedContent() returns Optional.empty(), which maps to ConstantFragment.EMPTY through the fallback logic above.

This means the default content check is robust even when <#nested/> appears outside a macro context.

Design Note: Why Default Nested Content Does Not Receive Nested Parameters

There is one deliberate limitation worth documenting:

Inside a nested default block, nested parameter identifiers are not available as bound variables. Only external (ambient) scope variables can be used.

Why this is the current behavior

Looking at the runtime branch, default content is executed with nestedDefault.process(context) — without wrapping in NestedInstructionEnvironment.

The reasoning behind this choice

  1. No hidden failure modes. If parameter names were silently bound to null in the default block, using them would produce empty output or runtime errors — confusing for macro authors.
  2. Default content is author-owned, not caller-controlled. Parameters in nested come from the macro body (e.g., <#nested item.name looper/>). They are values the macro explicitly passes to the calling block. With no caller block present, there is also no receiving context for those values. Binding them into the default block would require a decision about what “default parameter values” would even mean.
  3. Simpler and unsurprising semantics. Default content behaves like static fallback text with ambient scope access. This maps directly to how template authors intuitively think about fallback content.

Future direction

If parameter-aware defaults become needed, the fix would be to wrap nestedDefault.process(context) in the same NestedInstructionEnvironment path, using either default values from nested identifiers or explicit expression evaluation. That would be a targeted change to NestedInstructionFragment with well-defined semantics.

Closing Thoughts

The default content feature is compact in terms of code change but covers a real authoring pain point. Key implementation decisions — the grammar’s optional block, the ConstantFragment.EMPTY sentinel, and the deliberate absence of parameter binding in the fallback path — all contribute to a feature that integrates cleanly with the macro execution model without adding hidden complexity.

Leave a Comment