Search code examples
freemarker

Freemarker - How to get unprocessed content of custom directive?


I need to create a Freemarker directive that will pass the unprocessed content of the tag to a java method. The purpose is to load default template fragments into a DB so they can be examined and edited through another process, then output the result of the edited content if any. I have created a macro that almost does this, the problem is that the template fragment is processed before being passed to the java method.

Here is my current code:

<#macro process name>
    <#local content><#nested/></#local>
    ${myjava.processContent(name, content)}
</#macro>

<@process 'this-content-name'><p>This is some content about ${companyName}</p></@process>

Java:

//Add and instance of this class to the Freemarker data model for use in templates.
public class MyJava {

    public String processContent(String name, String content) {
        log.debug("Data To Process: name = {}, content = {}", name, content);

        String contentToOutput = getFromDb(name);
        if(contentToOutput == null) {
            insertContentInDb(name, content);
            contentToOutput = content;
        }
        
        TemplateModel model = Environment.getCurrentEnvironment().getDataModel();
        Configuration config = Environment.getCurrentEnvironment().getConfiguration();
        Template template = new Template("template-from-content", contentToOutput, config);
        return FreeMarkerTemplateUtils.processTemplateIntoString(template, model);
    }
}

When running the above I need to see the following logged:

Data To Process: name = this-content-name, content = <p>This is some content about ${companyName}</p>

Unfortunately I see this:

Data To Process: name = this-content-name, content = <p>This is some content about My Company Name</p>

The problem is clearly that the <#nested/> tag is processed by the template engine before it is stored in the local variable in my macro.

Looking into the Freemarker source code I see that Environment has invokeNestedContent() that appears to handle processing the content of the macro and appears to have methods for retrieving the raw content of the tag, i.e. <p>This is some content about ${companyName}</p>. Unfortunately those methods have package-private access and are not accessible from my code.

Is there another way to retrieve the RAW unprocessed content of a custom directive in Freemarker?

It seems like this would be very useful functionality for building content management systems.


Solution

  • Based on the suggestion in @ddekany's answer I was able to come up with two potential solutions which each have some trade offs.

    1. Using TemplateDirectiveModel and Environment.getCurrentDirectiveCallPlace() instead of a macro. The downside of this method is that I am not able to access the containing page's data_model when rendering the TemplateDirectiveModel. Additionally, the content retreived includes the full tag, not just its nested content so I have to parse that out.

    2. Implementing a freemarker utility class in package freemarker.core inside my own source tree. This allows me to access package-private methods, but is obviously not supported by Freemarker.

    Option 1 Java:

    @Slf4j
    public class ContentTemplateDirective implements TemplateDirectiveModel {
    
        private HostData hostDataService;
    
        public ContentTemplateDirective(HostData hostDataService) {
            this.hostDataService = hostDataService;
        }
    
        @Override
        public void execute(Environment environment, Map params, TemplateModel[] loopVars,
                            TemplateDirectiveBody templateDirectiveBody) throws TemplateException, IOException {
    
    
            if (!params.containsKey("name")) {
                throw new TemplateModelException(
                        "This directive requires a 'name' parameter.");
            }
            if (loopVars.length != 0) {
                throw new TemplateModelException(
                        "This directive doesn't allow loop variables.");
            }
    
            String name = params.get("name").toString();
            log.debug("name = {}", name);
    
            String content = getRawDirectiveContent(true);
            content = hostDataService.removeTag(content, "<@content>");
            log.debug("content = {}", content);
    
            //This looks for an Element with this name in the DB. If it is not found it creates one with this content.
            //If it is found it overrides this content with the content from the DB.
            content = hostDataService.processElement(name, content, true);
    
    
            Configuration config = environment.getConfiguration();
            TemplateModel model = environment.getDataModel();
            Template template = new Template("string-template", content, config);
    
            StringWriter result = new StringWriter(1024);
            template.process(model, result);
            String output = result.toString();
            
            //Write the output.
            environment.getOut().write(output);
        }
    
        public static String getRawDirectiveContent(boolean trim) {
            log.debug("Getting Raw Directive Content");
            DirectiveCallPlace dcp = Environment.getCurrentEnvironment().getCurrentDirectiveCallPlace();
    
            String content =  dcp.getTemplate().getSource(dcp.getBeginColumn(), dcp.getBeginLine(), dcp.getEndColumn(), dcp.getEndLine());
            
            return trim ? content.trim() : content;
        }
    }
    

    Option 1 FTL:

    <#assign content = "com.myapp.ContentTemplateDirective"?new(hostDataService)>
    
    <!-- Does not work. Fails to find testValue when evaluating the content. -->
    <#assign testValue>Some Test Value</#assign> 
    <@content name='page.h2'><h2>This is the default content. Here is a test value: ${testValue}</h2></@content>
    
    <!-- Works, but the testValue has to be assigned inside the <@content> tag so can't access externally assigned model attributes. -->
    <@content name='page.h2'>
    <#assign testValue>Some Test Value</#assign> 
    <h2>This is the default content. Here is a test value: ${testValue}</h2></@content>
    

    Option 2 Java (in package freemarker.core):

    public class MyFreemarkerUtil {
    
        public static String getRawMacroContent(boolean trim) {
            log.debug("Getting Raw Macro Content");
    
            StringBuilder sb = new StringBuilder();
    
            Environment environment = Environment.getCurrentEnvironment();
            Macro.Context invokingMacroContext = environment.getCurrentMacroContext();
            TemplateObject callPlace = invokingMacroContext.callPlace;
            TemplateElement[] nestedContentBuffer = callPlace instanceof TemplateElement ? ((TemplateElement) callPlace).getChildBuffer() : null;
            if (nestedContentBuffer != null) {
                for (TemplateElement templateElement : nestedContentBuffer) {
                    log.debug("templateElement = {}", templateElement);
                    sb.append(templateElement.getSource());
                }
            }
    
            if(trim) {
                return sb.toString().trim();
            } else {
                return sb.toString();
            }
        }
    }
    

    Option 2 FTL:

    <#macro content name>
        <#local temlateSource = hostDataService.processContent(name)/>
        <#local inlineTemplate = temlateSource?interpret/>
        <@inlineTemplate/>
    </#macro>
    
    <!-- This works. It is able to find the 'testValue' assigned outside the <@content> tag.-->
    <#assign testValue>Some Test Value</#assign> 
    <@content 'page.h2'><h2>This is the default content. Here is a test value: ${testValue}</h2></@content>
    

    After reviewing my options I decided to go with #2 as it worked and should be easy to maintain as long as the Freemarker internal Macro code has package-private access.