This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 1e33f0918f feat(rest): new juneau-rest-server-view-freemarker module 
(TODO-84)
1e33f0918f is described below

commit 1e33f0918f013f285dc3d3661cc5b5c58597ddad
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 13:25:31 2026 -0400

    feat(rest): new juneau-rest-server-view-freemarker module (TODO-84)
---
 juneau-distrib/pom.xml                             |  25 +
 .../juneau-rest-server-view-freemarker/pom.xml     | 126 +++++
 .../view/freemarker/BasicFreemarkerResource.java   | 581 +++++++++++++++++++++
 .../rest/view/freemarker/FreemarkerView.java       | 218 ++++++++
 .../view/freemarker/FreemarkerViewRenderer.java    | 131 +++++
 .../juneau/rest/view/freemarker/package-info.java  | 102 ++++
 juneau-rest/pom.xml                                |   1 +
 juneau-utest/pom.xml                               |  21 +
 .../BasicFreemarkerResource_Builder_Test.java      | 238 +++++++++
 .../BasicFreemarkerResource_MockRest_Test.java     | 149 ++++++
 ...BasicFreemarkerResource_PathTraversal_Test.java | 111 ++++
 ...sicFreemarkerResource_SvlPathOverride_Test.java |  66 +++
 .../freemarker/FreemarkerViewRenderer_Test.java    |  60 +++
 .../rest/view/freemarker/FreemarkerView_Test.java  | 228 ++++++++
 .../test/resources/freemarker-templates/about.ftlh |  22 +
 .../test/resources/freemarker-templates/hello.ftlh |  22 +
 .../freemarker-templates/nested/inner.ftlh         |  22 +
 17 files changed, 2123 insertions(+)

diff --git a/juneau-distrib/pom.xml b/juneau-distrib/pom.xml
index 3b6ca2ddcc..3828098952 100644
--- a/juneau-distrib/pom.xml
+++ b/juneau-distrib/pom.xml
@@ -583,6 +583,31 @@
                                                                        
<destFileName>org.apache.juneau.rest.server.view.mustache_${project.version}.jar</destFileName>
                                                                </artifactItem>
 
+                                                               <!-- 
juneau-rest-server-view-freemarker -->
+                                                               <artifactItem>
+                                                                       
<outputDirectory>${project.build.directory}/src/lib</outputDirectory>
+                                                                       
<groupId>org.apache.juneau</groupId>
+                                                                       
<artifactId>juneau-rest-server-view-freemarker</artifactId>
+                                                                       
<version>${project.version}</version>
+                                                                       
<type>jar</type>
+                                                                       
<classifier>sources</classifier>
+                                                                       
<destFileName>apache-juneau-rest-server-view-freemarker-${project.version}-sources.jar</destFileName>
+                                                               </artifactItem>
+                                                               <artifactItem>
+                                                                       
<outputDirectory>${project.build.directory}/bin/lib</outputDirectory>
+                                                                       
<groupId>org.apache.juneau</groupId>
+                                                                       
<artifactId>juneau-rest-server-view-freemarker</artifactId>
+                                                                       
<version>${project.version}</version>
+                                                                       
<destFileName>apache-juneau-rest-server-view-freemarker-${project.version}.jar</destFileName>
+                                                               </artifactItem>
+                                                               <artifactItem>
+                                                                       
<outputDirectory>${project.build.directory}/bin/osgi</outputDirectory>
+                                                                       
<groupId>org.apache.juneau</groupId>
+                                                                       
<artifactId>juneau-rest-server-view-freemarker</artifactId>
+                                                                       
<version>${project.version}</version>
+                                                                       
<destFileName>org.apache.juneau.rest.server.view.freemarker_${project.version}.jar</destFileName>
+                                                               </artifactItem>
+
                                                                <!-- 
juneau-rest-server-springboot -->
                                                                <artifactItem>
                                                                        
<outputDirectory>${project.build.directory}/src/lib</outputDirectory>
diff --git a/juneau-rest/juneau-rest-server-view-freemarker/pom.xml 
b/juneau-rest/juneau-rest-server-view-freemarker/pom.xml
new file mode 100644
index 0000000000..d8d35438d4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-view-freemarker/pom.xml
@@ -0,0 +1,126 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
https://maven.apache.org/xsd/maven-4.0.0.xsd";>
+
+       <modelVersion>4.0.0</modelVersion>
+
+       <parent>
+               <groupId>org.apache.juneau</groupId>
+               <artifactId>juneau-rest</artifactId>
+               <version>9.5.0-SNAPSHOT</version>
+       </parent>
+
+       <artifactId>juneau-rest-server-view-freemarker</artifactId>
+       <name>Apache Juneau REST Server View — FreeMarker</name>
+       <description>Apache Juneau REST Server - Apache FreeMarker 
view-rendering bridge (BasicFreemarkerResource mixin + 
FreemarkerViewRenderer).</description>
+       <packaging>bundle</packaging>
+
+       <properties>
+               
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+               <freemarker.version>2.3.34</freemarker.version>
+       </properties>
+
+       <!--
+               Engine-agnostic POM stance (mirrors juneau-rest-server-view-jsp 
resolved decision #2,
+               juneau-rest-server-view-thymeleaf resolved decision #2, and
+               juneau-rest-server-view-mustache resolved decision #2).
+
+               The bridge module declares ONLY the Apache FreeMarker engine 
API in `provided` scope. NO
+               concrete FreeMarker engine dependency leaks into consumer 
projects through this module.
+               Consumers add the engine matching their deployment:
+                       - Juneau microservice / Jetty: add 
`org.freemarker:freemarker` directly; the bridge
+                         builds a default `freemarker.template.Configuration` 
lazily at first use when no
+                         bean is registered.
+                       - Spring Boot: add `spring-boot-starter-freemarker` 
(autoconfigures
+                         `freemarker.template.Configuration`); the bridge 
picks it up via
+                         
`BeanStore.getBean(freemarker.template.Configuration.class)`.
+                       - Custom: user supplies their own `Configuration` 
`@Bean` with whatever loaders /
+                         encodings / output formats they want.
+       -->
+       <dependencies>
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server</artifactId>
+                       <version>${project.version}</version>
+               </dependency>
+               <dependency>
+                       <groupId>org.freemarker</groupId>
+                       <artifactId>freemarker</artifactId>
+                       <version>${freemarker.version}</version>
+                       <scope>provided</scope>
+               </dependency>
+       </dependencies>
+
+       <build>
+               <plugins>
+                       <plugin>
+                               <groupId>org.apache.maven.plugins</groupId>
+                               <artifactId>maven-source-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>attach-sources</id>
+                                               <phase>verify</phase>
+                                               <goals>
+                                                       <goal>jar-no-fork</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.apache.felix</groupId>
+                               <artifactId>maven-bundle-plugin</artifactId>
+                               <extensions>true</extensions>
+                               <configuration>
+                                       
<supportIncrementalBuild>true</supportIncrementalBuild>
+                               </configuration>
+                               <executions>
+                                       <execution>
+                                               <id>bundle-manifest</id>
+                                               <phase>process-classes</phase>
+                                               <goals>
+                                                       <goal>manifest</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.apache.maven.plugins</groupId>
+                               <artifactId>maven-jar-plugin</artifactId>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.jacoco</groupId>
+                               <artifactId>jacoco-maven-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>default-prepare-agent</id>
+                                               <goals>
+                                                       
<goal>prepare-agent</goal>
+                                               </goals>
+                                       </execution>
+                                       <execution>
+                                               <id>default-report</id>
+                                               <phase>prepare-package</phase>
+                                               <goals>
+                                                       <goal>report</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+               </plugins>
+       </build>
+</project>
diff --git 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource.java
 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource.java
new file mode 100644
index 0000000000..b88ef49c27
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource.java
@@ -0,0 +1,581 @@
+/*
+ * 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 org.apache.juneau.rest.view.freemarker;
+
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.io.*;
+import java.util.*;
+
+import freemarker.core.*;
+import freemarker.template.*;
+
+import org.apache.juneau.commons.utils.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that wires Apache FreeMarker view-rendering onto any Juneau REST 
resource.
+ *
+ * <p>
+ * Compose into a host resource via
+ * {@link Rest#mixins() @Rest(mixins=BasicFreemarkerResource.class)}; the host 
then:
+ *
+ * <ol class='spaced-list'>
+ *     <li>Gains a default mount at {@code /freemarker/*} that serves raw 
FreeMarker templates from
+ *             the importer's classpath by asking the configured {@link 
Configuration} for the named
+ *             template and rendering it with an empty data model (raw render 
path; callers who want
+ *             attributes use {@link FreemarkerView} from a typed handler 
instead).
+ *     <li>Picks up {@link FreemarkerViewRenderer} automatically via the 
mixin's
+ *             {@link Rest#responseProcessors() @Rest(responseProcessors=...)} 
declaration, so
+ *             {@code @RestOp}-method return values of type {@link 
FreemarkerView} render through the
+ *             FreeMarker engine without any additional wiring.
+ * </ol>
+ *
+ * <h5 class='figure'>Composition example (microservice):</h5>
+ *
+ * <p class='bjava'>
+ *     <ja>@Rest</ja>(path=<js>"/app"</js>, 
mixins=BasicFreemarkerResource.<jk>class</jk>)
+ *     <jk>public class</jk> AppResource <jk>extends</jk> RestServlet {
+ *
+ *             <ja>@Bean</ja> BasicFreemarkerResource freemarker() {
+ *                     <jk>return</jk> 
BasicFreemarkerResource.<jsm>create</jsm>()
+ *                             .basePath(<js>"/templates/"</js>)
+ *                             .templateSuffix(<js>".ftlh"</js>)
+ *                             .build();
+ *             }
+ *
+ *             <ja>@RestGet</ja>(<js>"/hello/{name}"</js>)
+ *             <jk>public</jk> View hello(<ja>@Path</ja> String <jv>name</jv>) 
{
+ *                     <jk>return</jk> 
FreemarkerView.<jsm>of</jsm>(<js>"hello"</js>).attr(<js>"name"</js>, 
<jv>name</jv>);
+ *             }
+ *     }
+ * </p>
+ *
+ * <h5 class='section'>Configurable mount path:</h5>
+ *
+ * <p>
+ * The default mount {@code /freemarker/*} can be overridden via the SVL 
variable
+ * {@code ${juneau.freemarker.path:freemarker}} &mdash; set via system property
+ * ({@code -Djuneau.freemarker.path=views}), environment variable
+ * ({@code JUNEAU_FREEMARKER_PATH=views}), or {@code Config} key
+ * ({@code juneau.freemarker.path = views}) to change the runtime mount 
without subclassing.
+ * Resolution happens once at {@link RestContext} construction time; see
+ * {@code FINISHED-99-svl-in-op-paths.md} for the full resolution chain.
+ *
+ * <h5 class='section'>Mixin-only deployment:</h5>
+ *
+ * <p>
+ * This resource is designed for composition via {@code @Rest(mixins=...)}. 
The mount path is
+ * pinned at the op level by
+ * {@link RestGet @RestGet(path="/${juneau.freemarker.path:freemarker}/*")} on 
{@link #render};
+ * a class-level {@code @Rest(paths=...)} declaration would be silently 
ignored under the mixin
+ * pattern (see {@link Rest#paths() @Rest(paths)} Javadoc).
+ *
+ * <h5 class='section'>Choosing a Configuration:</h5>
+ *
+ * <p>
+ * The {@code juneau-rest-server-view-freemarker} module ships <b>only</b>
+ * {@code org.freemarker:freemarker} in {@code provided} scope. <b>No 
configuration bean is
+ * bundled.</b> The bridge resolves a {@link Configuration} at first use from 
three sources, in
+ * order:
+ *
+ * <ul class='spaced-list'>
+ *     <li><b>User-supplied bean:</b> register a {@code @Bean 
freemarker.template.Configuration}
+ *             (Spring) or
+ *             {@code 
BasicBeanStore.put(freemarker.template.Configuration.class, cfg)} 
(microservice).
+ *             Spring Boot's {@code spring-boot-starter-freemarker} 
autoconfigures one out of the box;
+ *             the bridge picks it up via {@code 
BeanStore.getBean(Configuration.class)}.
+ *     <li><b>Bridge default:</b> when nothing is registered, the bridge 
constructs a default
+ *             {@link Configuration} anchored on a classpath resource root 
derived from
+ *             {@link #getBasePath() basePath} ({@code "/templates/"} becomes
+ *             {@code ClassLoaderTemplateResolver}-equivalent prefix {@code 
"/templates"}). The default
+ *             pins {@code IncompatibleImprovements} to the bridge-tested 
minor version
+ *             ({@code Configuration.VERSION_2_3_34}), sets {@code 
DefaultEncoding} to {@code UTF-8},
+ *             uses {@code HTMLOutputFormat} so HTML escaping is the natural 
target, and applies
+ *             {@code TemplateUpdateDelayMilliseconds} per the {@link 
#isCacheTemplates() cacheTemplates}
+ *             flag (production-safe by default; users opt into hot-reload via
+ *             {@link Builder#cacheTemplates(boolean) cacheTemplates(false)}).
+ * </ul>
+ *
+ * <p>
+ * When no FreeMarker engine is on the classpath, the renderer surfaces
+ * {@link FreemarkerViewRenderer#NO_ENGINE_DIAGNOSTIC} naming the missing 
dependency.
+ *
+ * <h5 class='section'>Template suffix:</h5>
+ *
+ * <p>
+ * FreeMarker's API does not expose a configuration-level resolver suffix 
&mdash; the literal
+ * template name is what {@link Configuration#getTemplate(String) 
cfg.getTemplate(...)} sees. The
+ * {@link Builder#templateSuffix(String) templateSuffix(...)} builder knob 
fills the gap: when
+ * non-blank, the bridge appends the configured suffix to template names that 
don't already end
+ * with it (idempotent), so callers can write {@code 
FreemarkerView.of("hello")} or request
+ * {@code /freemarker/hello} and have it resolve to {@code hello.ftlh} on the 
classpath.
+ *
+ * <h5 class='section'>{@code .ftl} vs {@code .ftlh}:</h5>
+ *
+ * <p>
+ * FreeMarker auto-selects HTML escaping by file extension: {@code .ftlh} 
templates emit
+ * HTML-escaped output; {@code .ftl} templates emit raw output. For HTML 
responses, prefer
+ * {@code .ftlh} so a future attribute-binding change can't introduce an XSS 
regression.
+ *
+ * <h5 class='section'>Multiple base paths:</h5>
+ *
+ * <p>
+ * A host with {@code /templates/} and {@code /admin/templates/} template 
trees registers two
+ * {@code BasicFreemarkerResource} beans, each mounted via a subclass 
declaring its own op-level
+ * {@code @RestGet(path=...)} override and its own {@code basePath}. See the
+ * {@code BasicFreemarkerResource_MockRest_Test} test in {@code juneau-utest} 
for the canonical
+ * pattern.
+ *
+ * <h5 class='section'>OpenAPI surface:</h5>
+ *
+ * <p>
+ * The greedy {@code /*} handler is not API-meaningful and is excluded from 
generated
+ * Swagger / OpenAPI specs via {@link OpSwagger#ignore() 
@OpSwagger(ignore=true)}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link FreemarkerView}
+ *     <li class='jc'>{@link FreemarkerViewRenderer}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/FreemarkerViewSupport";>FreeMarker 
View Support</a>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerComposition";>REST Server 
&mdash; Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+// @formatter:off
+@Rest(
+       responseProcessors={FreemarkerViewRenderer.class}
+)
+public class BasicFreemarkerResource {
+
+       /** Default base path applied when no {@link Builder#basePath(String)} 
call has been made. */
+       public static final String DEFAULT_BASE_PATH = "/";
+
+       /** Default template suffix &mdash; empty (literal template names, no 
implicit suffix). */
+       public static final String DEFAULT_TEMPLATE_SUFFIX = "";
+
+       /** Default template-cache flag &mdash; {@code true} (production-safe). 
*/
+       public static final boolean DEFAULT_CACHE_TEMPLATES = true;
+
+       private final String basePath;
+       private final String templateSuffix;
+       private final boolean cacheTemplates;
+
+       // Lazy bridge-default configuration. Built on first call to 
resolveConfiguration(...) when
+       // no Configuration bean is registered in the request's BeanStore. 
Volatile so the
+       // double-checked-locking idiom is safe under concurrent first-request 
load.
+       private volatile Configuration defaultConfiguration;
+
+       /**
+        * Creates a new builder.
+        *
+        * @return A new builder.
+        */
+       public static Builder create() {
+               return new Builder();
+       }
+
+       /**
+        * No-arg constructor &mdash; uses {@link #DEFAULT_BASE_PATH} as the 
base path,
+        * {@link #DEFAULT_TEMPLATE_SUFFIX} as the template suffix, and
+        * {@link #DEFAULT_CACHE_TEMPLATES} as the cache flag.
+        *
+        * <p>
+        * The mixin walk falls back to this constructor when the
+        * {@link org.apache.juneau.commons.inject.BeanStore BeanStore} does 
not have a registered
+        * {@code BasicFreemarkerResource} bean.
+        */
+       public BasicFreemarkerResource() {
+               this(create());
+       }
+
+       /**
+        * Builder constructor.
+        *
+        * @param builder The builder. Must not be {@code null}.
+        */
+       protected BasicFreemarkerResource(Builder builder) {
+               basePath = builder.basePath;
+               templateSuffix = builder.templateSuffix;
+               cacheTemplates = builder.cacheTemplates;
+       }
+
+       /**
+        * Returns the base path under which template resources are resolved.
+        *
+        * <p>
+        * Used to derive the bridge-default configuration's classpath resource 
root, and as the
+        * boundary for the path-traversal check in {@link #render render(...)}.
+        *
+        * @return The base path. Never {@code null}.
+        */
+       public String getBasePath() {
+               return basePath;
+       }
+
+       /**
+        * Returns the configured template-name suffix.
+        *
+        * <p>
+        * When non-blank, the bridge appends this suffix to any template name 
that does not already
+        * end with it (idempotent). Defaults to {@link 
#DEFAULT_TEMPLATE_SUFFIX} (empty &mdash;
+        * literal names).
+        *
+        * @return The template suffix. Never {@code null}.
+        */
+       public String getTemplateSuffix() {
+               return templateSuffix;
+       }
+
+       /**
+        * Returns the template-cache flag.
+        *
+        * <p>
+        * {@code true} means the bridge-default {@link Configuration} pins
+        * {@code TemplateUpdateDelayMilliseconds} to {@code Long.MAX_VALUE} 
(cache forever &mdash;
+        * production-safe); {@code false} means {@code 0} (re-check on every 
request &mdash;
+        * suitable for dev hot-reload).
+        *
+        * @return The cache flag.
+        */
+       public boolean isCacheTemplates() {
+               return cacheTemplates;
+       }
+
+       /**
+        * Appends {@link #getTemplateSuffix()} to {@code name} if not already 
present (idempotent).
+        *
+        * @param name The template name. Must not be {@code null}.
+        * @return The template name with the configured suffix appended (if 
applicable).
+        */
+       public String applyTemplateSuffix(String name) {
+               if (templateSuffix == null || templateSuffix.isEmpty())
+                       return name;
+               if (name.endsWith(templateSuffix))
+                       return name;
+               return name + templateSuffix;
+       }
+
+       /**
+        * Resolves the active {@link Configuration}.
+        *
+        * <p>
+        * Lookup order:
+        * <ol class='spaced-list'>
+        *      <li>{@code 
req.getContext().getBeanStore().getBean(Configuration.class)} &mdash; any
+        *              user-supplied bean (Spring {@code @Bean} including 
Spring Boot autoconfig, microservice
+        *              {@code BasicBeanStore.put}, etc.).
+        *      <li>Lazy bridge default &mdash; constructed on first call when 
no configuration bean is
+        *              registered. Anchored on a classpath resource root 
derived from {@link #basePath}, with
+        *              {@code IncompatibleImprovements} pinned to the 
bridge-tested minor version.
+        * </ol>
+        *
+        * @param req The current REST request.
+        * @return The active FreeMarker configuration. Never {@code null}.
+        */
+       public Configuration resolveConfiguration(RestRequest req) {
+               var bean = 
req.getContext().getBeanStore().getBean(Configuration.class);
+               if (bean.isPresent())
+                       return bean.get();
+               var local = defaultConfiguration;
+               if (local == null) {
+                       synchronized (this) {
+                               local = defaultConfiguration;
+                               if (local == null) {
+                                       local = buildDefaultConfiguration();
+                                       defaultConfiguration = local;
+                               }
+                       }
+               }
+               return local;
+       }
+
+       /**
+        * Constructs the bridge-default {@link Configuration}.
+        *
+        * <p>
+        * Anchored on a classpath resource root derived from {@link #basePath} 
(leading + trailing
+        * slashes trimmed; a {@code "/"} base yields a root-of-classpath 
resolver). The default pins
+        * {@code IncompatibleImprovements} to {@link 
Configuration#VERSION_2_3_34} so behavior is
+        * stable across consumer upgrades of {@code 
org.freemarker:freemarker}; sets
+        * {@code DefaultEncoding} to {@code UTF-8} and {@code OutputFormat} to
+        * {@link HTMLOutputFormat#INSTANCE} so HTML escaping is the natural 
target; and applies
+        * {@code TemplateUpdateDelayMilliseconds} per the configured
+        * {@link #isCacheTemplates() cacheTemplates} flag.
+        *
+        * <p>
+        * Subclasses may override to plug in custom loaders / encodings / 
output formats without
+        * registering a separate {@code @Bean Configuration}.
+        *
+        * @return A new {@link Configuration} instance.
+        */
+       protected Configuration buildDefaultConfiguration() {
+               var cfg = new Configuration(Configuration.VERSION_2_3_34);
+               
cfg.setClassLoaderForTemplateLoading(BasicFreemarkerResource.class.getClassLoader(),
 toResourceRoot(basePath));
+               cfg.setDefaultEncoding("UTF-8");
+               cfg.setOutputFormat(HTMLOutputFormat.INSTANCE);
+               cfg.setTemplateUpdateDelayMilliseconds(cacheTemplates ? 
Long.MAX_VALUE : 0L);
+               return cfg;
+       }
+
+       /**
+        * Translates a virtual base path (e.g. {@code "/templates/"}) into a 
FreeMarker
+        * {@code ClassLoaderTemplateLoader} resource root (e.g. {@code 
"/templates"}).
+        *
+        * <p>
+        * FreeMarker's
+        * {@link Configuration#setClassLoaderForTemplateLoading(ClassLoader, 
String)
+        * setClassLoaderForTemplateLoading(ClassLoader, String 
basePackagePath)} expects a
+        * classpath-relative root with a leading {@code "/"}. We trim any 
trailing slash and ensure a
+        * single leading slash; a {@code null} / blank / {@code "/"} base 
yields {@code "/"} (root of
+        * classpath).
+        *
+        * @param base The virtual base path.
+        * @return The FreeMarker classloader resource root (never {@code 
null}; always starts with
+        *      {@code "/"}).
+        */
+       static String toResourceRoot(String base) {
+               if (base == null || base.isBlank())
+                       return "/";
+               var s = base;
+               while (s.endsWith("/") && s.length() > 1)
+                       s = s.substring(0, s.length() - 1);
+               if (! s.startsWith("/"))
+                       s = "/" + s;
+               return s;
+       }
+
+       /**
+        * [GET /freemarker/*] &mdash; render a raw template through the 
FreeMarker engine.
+        *
+        * <p>
+        * The {@code @Path("/*") String path} captures the multi-segment 
trailing remainder (e.g. a
+        * request for {@code /freemarker/about.ftlh} matches the mount with
+        * {@code path = "about.ftlh"}; a request for {@code 
/freemarker/admin/dashboard.ftlh} matches
+        * with {@code path = "admin/dashboard.ftlh"}). Behavior:
+        *
+        * <ol class='spaced-list'>
+        *      <li>Validate the resolved {@code <basePath><path>} via
+        *              {@link FileUtils#resolveVirtualPathSafely(String, 
String)} &mdash; reject any
+        *              {@code ..} traversal with HTTP 403.
+        *      <li>Apply the configured template suffix idempotently (e.g. with
+        *              {@code templateSuffix(".ftlh")}, a request for {@code 
/freemarker/about} resolves
+        *              template {@code "about.ftlh"} via {@link 
#applyTemplateSuffix}).
+        *      <li>Ask the active {@link Configuration} for the template and 
process it with an empty
+        *              data model directly onto the response writer; request 
attributes and parameters are
+        *              not auto-bound (callers who want attributes use {@link 
FreemarkerView} from a typed
+        *              handler instead).
+        * </ol>
+        *
+        * <p>
+        * Missing template surfaces as the engine's own {@link 
TemplateNotFoundException}
+        * (HTTP 500 from the renderer's catch-all); missing engine surfaces as
+        * {@link FreemarkerViewRenderer#NO_ENGINE_DIAGNOSTIC}.
+        *
+        * @param path The trailing path segment after {@code /freemarker/} 
(the template name
+        *      relative to the configured {@link #getBasePath() base path}; 
configured suffix appended
+        *      if missing).
+        * @param req The current REST request.
+        * @param res The current REST response.
+        * @throws IOException If the underlying response writer fails.
+        * @throws BasicHttpException On boundary violation (403), missing 
engine (500), or render
+        *      failure (500).
+        */
+       @RestGet(
+               path="/${juneau.freemarker.path:freemarker}/*",
+               summary="FreeMarker view",
+               description="Render a raw FreeMarker template under the 
configured base path.",
+               swagger=@OpSwagger(ignore=true)
+       )
+       public void render(@Path("/*") String path, RestRequest req, 
RestResponse res)
+                       throws IOException, BasicHttpException {
+
+               var template = (path == null) ? "" : path;
+
+               // resolveVirtualPathSafely validates that the resolved virtual 
path stays inside the
+               // configured basePath. The traversal check operates on 
basePath + template so a template
+               // name like "../../etc/passwd" or "a/b/../../../secret" is 
rejected before reaching the
+               // engine. We re-derive the configuration-relative template 
name from the safe result by
+               // stripping the (already-normalized) basePath prefix.
+               String safeTemplate;
+               try {
+                       var resolved = 
FileUtils.resolveVirtualPathSafely(basePath, template);
+                       safeTemplate = stripBasePath(basePath, resolved);
+               } catch (@SuppressWarnings("unused") IllegalArgumentException 
ex) {
+                       throw new Forbidden("Path escapes configured base 
path.");
+               }
+
+               // Apply suffix idempotently so /freemarker/about resolves to 
about.ftlh (when
+               // templateSuffix is configured), while /freemarker/about.ftlh 
stays as-is.
+               safeTemplate = applyTemplateSuffix(safeTemplate);
+
+               try {
+                       var cfg = resolveConfiguration(req);
+                       if (! res.containsHeader("Content-Type"))
+                               res.setHeader("Content-Type", 
FreemarkerViewRenderer.DEFAULT_CONTENT_TYPE);
+                       var tpl = cfg.getTemplate(safeTemplate);
+                       tpl.process(Map.of(), res.getWriter());
+                       res.getWriter().flush();
+               } catch (LinkageError ex) {
+                       throw new InternalServerError(ex, 
FreemarkerViewRenderer.NO_ENGINE_DIAGNOSTIC);
+               } catch (IOException ex) {
+                       throw ex;
+               } catch (BasicHttpException ex) {
+                       throw ex;
+               } catch (TemplateException ex) {
+                       throw new InternalServerError(ex, "FreeMarker render 
failed for ''{0}''", safeTemplate);
+               } catch (RuntimeException ex) {
+                       throw new InternalServerError(ex, "FreeMarker render 
failed for ''{0}''", safeTemplate);
+               }
+       }
+
+       /**
+        * Strips the normalized {@code basePath} prefix from a {@code 
resolveVirtualPathSafely}
+        * result so the leftover string is configuration-relative (the 
bridge's default configuration
+        * re-adds the configured resource-root prefix via its template loader).
+        *
+        * <p>
+        * Normalization mirrors {@code FileUtils.resolveVirtualPathSafely}: a 
{@code null} / empty
+        * base normalizes to {@code "/"}; otherwise the base is guaranteed to 
start with {@code "/"}
+        * and end with {@code "/"} once the helper has normalized it.
+        *
+        * @param base The configured base path (typically {@code 
"/templates/"}).
+        * @param resolved The output of {@code resolveVirtualPathSafely} 
(always starts with the
+        *      normalized base).
+        * @return The configuration-relative template name (e.g. {@code 
"hello.ftlh"} for base
+        *      {@code "/templates/"} and resolved {@code 
"/templates/hello.ftlh"}).
+        */
+       static String stripBasePath(String base, String resolved) {
+               var bp = (base == null || base.isEmpty()) ? "/" : base;
+               if (! bp.endsWith("/"))
+                       bp = bp + "/";
+               if (! bp.startsWith("/"))
+                       bp = "/" + bp;
+               if (resolved.startsWith(bp))
+                       return resolved.substring(bp.length());
+               throw illegalArg("Resolved path ''{0}'' does not start with 
base ''{1}''", resolved, bp);
+       }
+
+       /**
+        * Builder for {@link BasicFreemarkerResource}.
+        */
+       public static class Builder {
+
+               private String basePath = DEFAULT_BASE_PATH;
+               private String templateSuffix = DEFAULT_TEMPLATE_SUFFIX;
+               private boolean cacheTemplates = DEFAULT_CACHE_TEMPLATES;
+
+               /** Constructor &mdash; package access for {@link 
BasicFreemarkerResource#create()}. */
+               protected Builder() {}
+
+               /**
+                * Sets the classpath base path under which template resources 
are resolved.
+                *
+                * <p>
+                * Defaults to {@link BasicFreemarkerResource#DEFAULT_BASE_PATH 
"/"}. A typical configured
+                * value is {@code "/templates/"} &mdash; the same 
Spring-Boot-compatible layout
+                * convention the sibling JSP / Thymeleaf / Mustache bridges 
use.
+                *
+                * @param value The base path. {@code null} or blank values 
reset to the default
+                *      {@link BasicFreemarkerResource#DEFAULT_BASE_PATH}.
+                * @return This object.
+                */
+               public Builder basePath(String value) {
+                       basePath = (value == null || value.isBlank()) ? 
DEFAULT_BASE_PATH : value;
+                       return this;
+               }
+
+               /**
+                * Sets the suffix to append to template names that don't 
already end with it
+                * (idempotent).
+                *
+                * <p>
+                * Defaults to {@link 
BasicFreemarkerResource#DEFAULT_TEMPLATE_SUFFIX ""} (no implicit
+                * suffix &mdash; literal template names). Typical configured 
values are {@code ".ftlh"}
+                * (HTML auto-escape) or {@code ".ftl"} (no auto-escape).
+                *
+                * @param value The new suffix. {@code null} resets to
+                *      {@link BasicFreemarkerResource#DEFAULT_TEMPLATE_SUFFIX}.
+                * @return This object.
+                */
+               public Builder templateSuffix(String value) {
+                       templateSuffix = (value == null) ? 
DEFAULT_TEMPLATE_SUFFIX : value;
+                       return this;
+               }
+
+               /**
+                * Sets the template-cache flag for the bridge-default 
configuration.
+                *
+                * <p>
+                * Defaults to {@link 
BasicFreemarkerResource#DEFAULT_CACHE_TEMPLATES true} (cache
+                * forever &mdash; production-safe). Set {@code false} to 
enable dev hot-reload (FreeMarker
+                * re-checks the underlying resource on every render).
+                *
+                * <p>
+                * This flag only affects the bridge-default {@link 
Configuration}; user-supplied
+                * {@code @Bean Configuration}s are not modified.
+                *
+                * @param value The cache flag.
+                * @return This object.
+                */
+               public Builder cacheTemplates(boolean value) {
+                       cacheTemplates = value;
+                       return this;
+               }
+
+               /**
+                * Reads the current base path setting (test/inspection helper).
+                *
+                * @return The base path. Never {@code null}.
+                */
+               public String getBasePath() {
+                       return basePath;
+               }
+
+               /**
+                * Reads the current template-suffix setting (test/inspection 
helper).
+                *
+                * @return The template suffix. Never {@code null}.
+                */
+               public String getTemplateSuffix() {
+                       return templateSuffix;
+               }
+
+               /**
+                * Reads the current cache-templates setting (test/inspection 
helper).
+                *
+                * @return The cache flag.
+                */
+               public boolean isCacheTemplates() {
+                       return cacheTemplates;
+               }
+
+               /**
+                * Builds the {@link BasicFreemarkerResource}.
+                *
+                * @return A new {@link BasicFreemarkerResource} instance.
+                */
+               public BasicFreemarkerResource build() {
+                       if (basePath == null)
+                               throw illegalArg("basePath must not be null");
+                       return new BasicFreemarkerResource(this);
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerView.java
 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerView.java
new file mode 100644
index 0000000000..588edefa58
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerView.java
@@ -0,0 +1,218 @@
+/*
+ * 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 org.apache.juneau.rest.view.freemarker;
+
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.util.*;
+
+import org.apache.juneau.rest.view.*;
+
+/**
+ * Immutable value class returned from {@code @RestOp}-annotated methods to 
ask the framework to
+ * render an Apache FreeMarker template.
+ *
+ * <p>
+ * Companion to {@link BasicFreemarkerResource} and {@link 
FreemarkerViewRenderer}: the mixin sets
+ * up the {@code /freemarker/*} mount and registers the renderer; the renderer 
detects
+ * {@code FreemarkerView} returns in the response-processor chain and asks the 
configured
+ * {@code freemarker.template.Configuration} to {@code 
getTemplate(templateName)} and then
+ * {@code process(dataModel, writer)} directly onto the response writer.
+ *
+ * <h5 class='figure'>Usage:</h5>
+ *
+ * <p class='bjava'>
+ *     <ja>@RestGet</ja>(<js>"/hello/{name}"</js>)
+ *     <jk>public</jk> View hello(<ja>@Path</ja> String <jv>name</jv>) {
+ *             <jk>return</jk> 
FreemarkerView.<jsm>of</jsm>(<js>"hello.ftlh"</js>)
+ *                     .attr(<js>"name"</js>, <jv>name</jv>)
+ *                     .attr(<js>"ts"</js>, Instant.<jsm>now</jsm>());
+ *     }
+ * </p>
+ *
+ * <p>
+ * The template name is configuration-relative &mdash; the configured
+ * {@code freemarker.template.Configuration} template loader prepends its own 
resource root. With
+ * the bridge's default configuration + {@code basePath("/templates/")},
+ * {@code FreemarkerView.of("hello.ftlh")} resolves to {@code 
/templates/hello.ftlh} on the
+ * classpath.
+ *
+ * <h5 class='section'>{@code .ftl} vs {@code .ftlh} auto-escape:</h5>
+ *
+ * <p>
+ * FreeMarker auto-selects HTML escaping by file extension: {@code .ftlh} 
templates emit
+ * HTML-escaped output (variable references are escaped automatically), while 
{@code .ftl}
+ * templates emit raw output. For HTML responses, prefer the {@code .ftlh} 
extension to avoid an
+ * XSS regression sneaking in via a future attribute-binding change.
+ *
+ * <h5 class='section'>Template suffix:</h5>
+ *
+ * <p>
+ * The {@link BasicFreemarkerResource.Builder#templateSuffix(String) 
templateSuffix(...)} builder
+ * knob lets the bridge append a suffix (e.g. {@code ".ftlh"}) to template 
names that don't
+ * already end with it, so callers can write {@code 
FreemarkerView.of("hello")} and have the
+ * bridge resolve it as {@code "hello.ftlh"} on the classpath (idempotent 
&mdash; explicit suffix
+ * is honored).
+ *
+ * <h5 class='section'>Immutability:</h5>
+ *
+ * <p>
+ * Each {@code attr(...)} / {@code attrs(...)} / {@code header(...)} call 
returns a <b>new</b>
+ * {@code FreemarkerView} carrying the additional binding; the original 
instance is unchanged.
+ * This keeps {@code FreemarkerView} a value type safe to share across 
requests (e.g. as a static
+ * singleton built once and returned from many handlers).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link BasicFreemarkerResource}
+ *     <li class='jc'>{@link FreemarkerViewRenderer}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/FreemarkerViewSupport";>FreeMarker 
View Support</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public final class FreemarkerView implements View {
+
+       private final String templateName;
+       private final Map<String, Object> attributes;
+       private final Map<String, String> responseHeaders;
+
+       /**
+        * Creates a new {@code FreemarkerView} carrying the given template 
name and no attributes.
+        *
+        * <p>
+        * The template name is configuration-relative; the configured
+        * {@code freemarker.template.Configuration} template loader supplies 
the prefix (resource
+        * root). With the bridge's default configuration + {@code 
basePath("/templates/")}, the name
+        * {@code "hello.ftlh"} resolves to {@code /templates/hello.ftlh} on 
the classpath.
+        *
+        * @param templateName The template name (configuration-relative, 
suffix optional when the
+        *      mixin's {@link 
BasicFreemarkerResource.Builder#templateSuffix(String) templateSuffix} is
+        *      configured). Must not be {@code null} or blank.
+        * @return A new {@code FreemarkerView} instance.
+        * @throws IllegalArgumentException If {@code templateName} is {@code 
null} or blank.
+        */
+       public static FreemarkerView of(String templateName) {
+               if (templateName == null || templateName.isBlank())
+                       throw illegalArg("templateName must not be null or 
blank");
+               return new FreemarkerView(templateName, Map.of(), Map.of());
+       }
+
+       private FreemarkerView(String templateName, Map<String, Object> 
attributes, Map<String, String> responseHeaders) {
+               this.templateName = templateName;
+               this.attributes = attributes;
+               this.responseHeaders = responseHeaders;
+       }
+
+       /**
+        * Returns a copy of this view with the given attribute added (or 
replaced if a binding for
+        * {@code key} already exists).
+        *
+        * <p>
+        * The full attribute map is passed to FreeMarker as the data model, so 
each entry is
+        * accessible inside the template as {@code ${key}}. {@code null} 
values are rejected at
+        * build-time because FreeMarker's default null-handling renders 
missing/null bindings as an
+        * error or empty string depending on configuration, which typically 
masks what is a caller
+        * bug; rejecting up-front surfaces the mistake at the call site 
(matches the
+        * Thymeleaf / Mustache bridge precedents).
+        *
+        * @param key The attribute key (exposed inside the template as {@code 
${key}}). Must not be
+        *      {@code null} or blank.
+        * @param value The attribute value. Must not be {@code null}.
+        * @return A new {@code FreemarkerView} carrying the additional 
attribute.
+        * @throws IllegalArgumentException If {@code key} is {@code null} or 
blank, or {@code value}
+        *      is {@code null}.
+        */
+       public FreemarkerView attr(String key, Object value) {
+               if (key == null || key.isBlank())
+                       throw illegalArg("attribute key must not be null or 
blank");
+               if (value == null)
+                       throw illegalArg("attribute value must not be null 
(attribute ''{0}'')", key);
+               var copy = new LinkedHashMap<>(attributes);
+               copy.put(key, value);
+               return new FreemarkerView(templateName, Map.copyOf(copy), 
responseHeaders);
+       }
+
+       /**
+        * Returns a copy of this view with all entries from the given map 
added (or replaced if a
+        * binding for any key already exists).
+        *
+        * @param values The attributes to add. {@code null} is treated as an 
empty map (the result is
+        *      the same instance with no entries added).
+        * @return A new {@code FreemarkerView} carrying the additional 
attributes.
+        * @throws IllegalArgumentException If any entry has a {@code 
null}/blank key or a {@code null}
+        *      value.
+        */
+       public FreemarkerView attrs(Map<String, ?> values) {
+               if (values == null || values.isEmpty())
+                       return this;
+               var copy = new LinkedHashMap<>(attributes);
+               values.forEach((k, v) -> {
+                       if (k == null || k.isBlank())
+                               throw illegalArg("attribute key must not be 
null or blank");
+                       if (v == null)
+                               throw illegalArg("attribute value must not be 
null (attribute ''{0}'')", k);
+                       copy.put(k, v);
+               });
+               return new FreemarkerView(templateName, Map.copyOf(copy), 
responseHeaders);
+       }
+
+       /**
+        * Returns a copy of this view with the given response header set on 
the rendered response.
+        *
+        * <p>
+        * Typical usage: {@code .header("Content-Type", "text/html; 
charset=UTF-8")} or
+        * {@code .header("Cache-Control", "no-store")}. The renderer applies 
these headers via
+        * {@link jakarta.servlet.http.HttpServletResponse#setHeader(String, 
String)
+        * HttpServletResponse.setHeader(...)} before writing the rendered 
output.
+        *
+        * @param name The header name. Must not be {@code null} or blank.
+        * @param value The header value. Must not be {@code null}.
+        * @return A new {@code FreemarkerView} carrying the additional 
response header.
+        * @throws IllegalArgumentException If {@code name} is {@code null} or 
blank, or {@code value}
+        *      is {@code null}.
+        */
+       public FreemarkerView header(String name, String value) {
+               if (name == null || name.isBlank())
+                       throw illegalArg("header name must not be null or 
blank");
+               if (value == null)
+                       throw illegalArg("header value must not be null (header 
''{0}'')", name);
+               var copy = new LinkedHashMap<>(responseHeaders);
+               copy.put(name, value);
+               return new FreemarkerView(templateName, attributes, 
Map.copyOf(copy));
+       }
+
+       @Override /* Overridden from View */
+       public String getTemplateName() {
+               return templateName;
+       }
+
+       @Override /* Overridden from View */
+       public Map<String, Object> getAttributes() {
+               return attributes;
+       }
+
+       @Override /* Overridden from View */
+       public Map<String, String> getResponseHeaders() {
+               return responseHeaders;
+       }
+
+       @Override /* Overridden from Object */
+       public String toString() {
+               return "FreemarkerView[template=" + templateName + ", attrs=" + 
attributes.keySet()
+                       + ", headers=" + responseHeaders.keySet() + "]";
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer.java
 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer.java
new file mode 100644
index 0000000000..89ff1a16b0
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer.java
@@ -0,0 +1,131 @@
+/*
+ * 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 org.apache.juneau.rest.view.freemarker;
+
+import java.io.*;
+
+import freemarker.template.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.processor.*;
+
+/**
+ * {@link ResponseProcessor} that detects {@link FreemarkerView}-typed return 
values and asks the
+ * configured {@link Configuration} to render them directly onto the response 
writer.
+ *
+ * <p>
+ * Auto-registered by {@link BasicFreemarkerResource} via
+ * {@link org.apache.juneau.rest.annotation.Rest#responseProcessors() 
@Rest(responseProcessors=...)}
+ * &mdash; callers who add the mixin don't need to wire up this class 
explicitly. Callers who want
+ * to handle {@code FreemarkerView} returns <i>without</i> adopting the mixin 
can add this class
+ * to their own
+ * {@link org.apache.juneau.rest.annotation.Rest#responseProcessors() 
responseProcessors} list.
+ *
+ * <h5 class='section'>Behavior:</h5>
+ *
+ * <ol class='spaced-list'>
+ *     <li>Inspect the response content. If the value is not a {@link 
FreemarkerView}, return
+ *             {@link ResponseProcessor#NEXT NEXT} so the rest of the chain 
runs.
+ *     <li>Read the active {@link BasicFreemarkerResource} from the {@code 
RestContext} bean store
+ *             to discover the {@link Configuration} (lazy default if no 
configuration bean is
+ *             registered) and the optional template-suffix knob.
+ *     <li>Apply every entry from {@link FreemarkerView#getResponseHeaders()} 
via
+ *             {@link 
jakarta.servlet.http.HttpServletResponse#setHeader(String, String)
+ *             res.setHeader(...)}.
+ *     <li>Default {@code Content-Type} to {@code text/html;charset=UTF-8} if 
the caller did not set
+ *             one explicitly. FreeMarker's bridge-default {@code 
Configuration} uses
+ *             {@code HTMLOutputFormat} so HTML is the natural target; an 
explicit caller header wins.
+ *     <li>Call {@code 
configuration.getTemplate(templateName).process(view.getAttributes(),
+ *             res.getWriter())} to stream the rendered output.
+ *     <li>When no FreeMarker engine is on the classpath, the {@link 
Configuration}-typed import
+ *             here fails to load at first use and surfaces {@link 
#NO_ENGINE_DIAGNOSTIC} naming the
+ *             missing dependency.
+ * </ol>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link BasicFreemarkerResource}
+ *     <li class='jc'>{@link FreemarkerView}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/ResponseProcessors";>Response 
Processors</a>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/FreemarkerViewSupport";>FreeMarker 
View Support</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class FreemarkerViewRenderer implements ResponseProcessor {
+
+       /** Default {@code Content-Type} applied when the view does not specify 
one explicitly. */
+       public static final String DEFAULT_CONTENT_TYPE = 
"text/html;charset=UTF-8";
+
+       /**
+        * Diagnostic message emitted when no FreeMarker engine can be found on 
the classpath at
+        * render-time.
+        *
+        * <p>
+        * Public so tests and consumer apps can pattern-match against the 
message text without
+        * depending on internal string literals.
+        */
+       public static final String NO_ENGINE_DIAGNOSTIC =
+               "No Apache FreeMarker engine is available on the classpath. 
Add:\n"
+               + "  - org.freemarker:freemarker                       
(FreeMarker engine core)\n"
+               + "Or, for Spring Boot:\n"
+               + "  - 
org.springframework.boot:spring-boot-starter-freemarker\n"
+               + "    (autoconfigures a freemarker.template.Configuration bean 
the bridge picks up).\n"
+               + "Or register a custom @Bean freemarker.template.Configuration 
with whatever loaders /\n"
+               + "encodings / output formats you need.\n"
+               + "See 
https://juneau.apache.org/docs/topics/FreemarkerViewSupport for the full 
matrix.";
+
+       @Override /* Overridden from ResponseProcessor */
+       public int process(RestOpSession opSession) throws IOException, 
BasicHttpException {
+               var req = opSession.getRequest();
+               var res = opSession.getResponse();
+
+               var content = res.getContent(Object.class);
+               if (! (content instanceof FreemarkerView view))
+                       return NEXT;
+
+               // Resolve the bridge resource (carries Configuration + cached 
default + templateSuffix).
+               // Fall back to a fresh BasicFreemarkerResource when the 
renderer is used standalone
+               // without the mixin.
+               var bridge = req.getContext().getBeanStore()
+                       .getBean(BasicFreemarkerResource.class)
+                       .orElseGet(BasicFreemarkerResource::new);
+
+               // Apply caller-supplied response headers first so a 
caller-provided Content-Type wins
+               // over the bridge's default below.
+               view.getResponseHeaders().forEach(res::setHeader);
+               if (! res.containsHeader("Content-Type"))
+                       res.setHeader("Content-Type", DEFAULT_CONTENT_TYPE);
+
+               var templateName = 
bridge.applyTemplateSuffix(view.getTemplateName());
+               try {
+                       var cfg = bridge.resolveConfiguration(req);
+                       var template = cfg.getTemplate(templateName);
+                       template.process(view.getAttributes(), res.getWriter());
+                       res.getWriter().flush();
+                       return FINISHED;
+               } catch (LinkageError ex) {
+                       throw new InternalServerError(ex, NO_ENGINE_DIAGNOSTIC);
+               } catch (IOException ex) {
+                       throw ex;
+               } catch (TemplateException ex) {
+                       throw new InternalServerError(ex, "FreeMarker render 
failed for ''{0}''", templateName);
+               } catch (RuntimeException ex) {
+                       throw new InternalServerError(ex, "FreeMarker render 
failed for ''{0}''", templateName);
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/package-info.java
 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/package-info.java
new file mode 100644
index 0000000000..3389612aff
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/package-info.java
@@ -0,0 +1,102 @@
+/*
+ * 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.
+ */
+/**
+ * Apache FreeMarker view-rendering bridge &mdash; opt-in {@code 
juneau-rest-server-view-freemarker}
+ * module.
+ *
+ * <p>
+ * Provides three sibling pieces that together let a Juneau REST resource 
render
+ * <a href="https://freemarker.apache.org/";>Apache FreeMarker</a> templates:
+ *
+ * <ul class='javatreec'>
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.view.freemarker.BasicFreemarkerResource} &mdash;
+ *             mixin that renders raw FreeMarker templates from the importer's 
classpath under
+ *             {@code /freemarker/*} and auto-registers
+ *             {@link 
org.apache.juneau.rest.view.freemarker.FreemarkerViewRenderer}.
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.view.freemarker.FreemarkerView} &mdash; immutable
+ *             value class (implements the core {@link 
org.apache.juneau.rest.view.View View} interface)
+ *             returned from {@code @RestOp} methods to ask the framework to 
render a FreeMarker
+ *             template.
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.view.freemarker.FreemarkerViewRenderer} &mdash;
+ *             {@link org.apache.juneau.rest.processor.ResponseProcessor 
ResponseProcessor} that detects
+ *             {@link org.apache.juneau.rest.view.freemarker.FreemarkerView 
FreemarkerView} returns and
+ *             dispatches them to the configured
+ *             {@link freemarker.template.Configuration Configuration} via
+ *             {@code cfg.getTemplate(templateName).process(dataModel, 
writer)}.
+ * </ul>
+ *
+ * <h5 class='figure'>Composition example (microservice):</h5>
+ *
+ * <p class='bjava'>
+ *     <ja>@Rest</ja>(path=<js>"/app"</js>, 
mixins=BasicFreemarkerResource.<jk>class</jk>)
+ *     <jk>public class</jk> AppResource <jk>extends</jk> RestServlet {
+ *
+ *             <ja>@Bean</ja> BasicFreemarkerResource freemarker() {
+ *                     <jk>return</jk> 
BasicFreemarkerResource.<jsm>create</jsm>()
+ *                             .basePath(<js>"/templates/"</js>)
+ *                             .templateSuffix(<js>".ftlh"</js>)
+ *                             .build();
+ *             }
+ *
+ *             <ja>@RestGet</ja>(<js>"/hello/{name}"</js>)
+ *             <jk>public</jk> View hello(<ja>@Path</ja> String <jv>name</jv>) 
{
+ *                     <jk>return</jk> 
FreemarkerView.<jsm>of</jsm>(<js>"hello"</js>).attr(<js>"name"</js>, 
<jv>name</jv>);
+ *             }
+ *     }
+ * </p>
+ *
+ * <h5 class='section'>Engine-agnostic packaging:</h5>
+ *
+ * <p>
+ * The bridge module's main POM declares <b>only</b> {@code 
org.freemarker:freemarker} in
+ * {@code provided} scope &mdash; <b>no concrete FreeMarker transitive dep</b> 
ships here.
+ * Consumers add the engine matching their deployment:
+ *
+ * <ul class='spaced-list'>
+ *     <li><b>Juneau microservice / Jetty:</b> add {@code 
org.freemarker:freemarker}. With no
+ *             user-supplied configuration bean, the bridge builds a default
+ *             {@link freemarker.template.Configuration Configuration} on 
first request anchored on the
+ *             importer's classpath under the configured base path.
+ *     <li><b>Spring Boot:</b> add {@code spring-boot-starter-freemarker}. 
Spring Boot
+ *             autoconfigures a {@code freemarker.template.Configuration} bean 
which the bridge picks
+ *             up automatically via {@code 
BeanStore.getBean(Configuration.class)} &mdash; no further
+ *             wiring required.
+ *     <li><b>Custom:</b> register your own {@code @Bean 
freemarker.template.Configuration} with
+ *             whatever loaders / encodings / output formats / template-update 
policies you need.
+ * </ul>
+ *
+ * <p>
+ * When no FreeMarker engine is on the classpath, the renderer surfaces a 
clear diagnostic
+ * naming the missing dependency and linking to the "Choosing a Configuration" 
matrix in
+ * {@code FreemarkerViewSupport.md}.
+ *
+ * <h5 class='section'>{@code .ftl} vs {@code .ftlh}:</h5>
+ *
+ * <p>
+ * FreeMarker auto-selects HTML escaping by file extension: {@code .ftlh} 
templates emit
+ * HTML-escaped output, while {@code .ftl} templates emit raw output. For HTML 
responses, prefer
+ * {@code .ftlh} so a future attribute-binding change can't introduce an XSS 
regression.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link org.apache.juneau.rest.view.View}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/FreemarkerViewSupport";>FreeMarker 
View Support</a>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerComposition";>REST Server 
&mdash; Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.view.freemarker;
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index 37fa14e046..6dd2e13936 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -40,6 +40,7 @@
                <module>juneau-rest-server-view-jsp</module>
                <module>juneau-rest-server-view-thymeleaf</module>
                <module>juneau-rest-server-view-mustache</module>
+               <module>juneau-rest-server-view-freemarker</module>
                <module>juneau-rest-server-springboot</module>
                <module>juneau-rest-client</module>
                <module>juneau-rest-client-classic</module>
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index 94dba56875..2c66fd21b4 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -247,6 +247,27 @@
                        <version>0.9.14</version>
                        <scope>test</scope>
                </dependency>
+               <!--
+                       FreeMarker view bridge + engine for the 
BasicFreemarkerResource / FreemarkerView test
+                       matrix. Engine-agnostic POM stance (TODO-84 resolved 
decision #2 / mirrored from
+                       TODO-78 #2 + TODO-82 #2 + TODO-83 #2): the bridge 
module itself has no concrete
+                       engine dep, so juneau-utest supplies the FreeMarker 
engine in `test` scope. Spring
+                       Boot's spring-boot-starter-freemarker is not added here 
in v1 — the real-container
+                       Spring-Boot integration matrix is deferred to a 
follow-on TODO behind the TODO-96
+                       response-processor-prepend prereq.
+               -->
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       
<artifactId>juneau-rest-server-view-freemarker</artifactId>
+                       <version>${project.version}</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>org.freemarker</groupId>
+                       <artifactId>freemarker</artifactId>
+                       <version>2.3.34</version>
+                       <scope>test</scope>
+               </dependency>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-web</artifactId>
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_Builder_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_Builder_Test.java
new file mode 100644
index 0000000000..9e3bb3bf84
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_Builder_Test.java
@@ -0,0 +1,238 @@
+/*
+ * 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 org.apache.juneau.rest.view.freemarker;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+import freemarker.template.*;
+
+/**
+ * Unit tests for the {@link BasicFreemarkerResource.Builder} contract, the
+ * {@link BasicFreemarkerResource#applyTemplateSuffix applyTemplateSuffix} 
helper, the
+ * {@link BasicFreemarkerResource#toResourceRoot toResourceRoot} helper, and 
the
+ * {@link BasicFreemarkerResource#stripBasePath stripBasePath} helper.
+ *
+ * <p>
+ * Mirrors the {@code BasicMustacheResource_Builder_Test} /
+ * {@code BasicThymeleafResource_Builder_Test} shape so sibling view modules 
can follow the same
+ * template.
+ *
+ * @since 9.5.0
+ */
+class BasicFreemarkerResource_Builder_Test extends TestBase {
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section A: builder surface
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void a01_defaultBasePathIsRoot() {
+               var r = BasicFreemarkerResource.create().build();
+               assertEquals(BasicFreemarkerResource.DEFAULT_BASE_PATH, 
r.getBasePath());
+               assertEquals("/", r.getBasePath());
+       }
+
+       @Test void a02_basePathSetterRoundTrips() {
+               var r = 
BasicFreemarkerResource.create().basePath("/templates/").build();
+               assertEquals("/templates/", r.getBasePath());
+       }
+
+       @Test void a03_basePathNullResetsToDefault() {
+               var r = BasicFreemarkerResource.create()
+                       .basePath("/templates/")
+                       .basePath(null)
+                       .build();
+               assertEquals(BasicFreemarkerResource.DEFAULT_BASE_PATH, 
r.getBasePath());
+       }
+
+       @Test void a04_basePathBlankResetsToDefault() {
+               var r = BasicFreemarkerResource.create()
+                       .basePath("/templates/")
+                       .basePath("   ")
+                       .build();
+               assertEquals(BasicFreemarkerResource.DEFAULT_BASE_PATH, 
r.getBasePath());
+       }
+
+       @Test void a05_builderGetBasePathReadsBeforeBuild() {
+               var b = BasicFreemarkerResource.create();
+               assertEquals(BasicFreemarkerResource.DEFAULT_BASE_PATH, 
b.getBasePath());
+               b.basePath("/templates/");
+               assertEquals("/templates/", b.getBasePath());
+       }
+
+       @Test void a06_noArgConstructorUsesDefaults() {
+               var r = new BasicFreemarkerResource();
+               assertEquals(BasicFreemarkerResource.DEFAULT_BASE_PATH, 
r.getBasePath());
+               assertEquals(BasicFreemarkerResource.DEFAULT_TEMPLATE_SUFFIX, 
r.getTemplateSuffix());
+               assertEquals(BasicFreemarkerResource.DEFAULT_CACHE_TEMPLATES, 
r.isCacheTemplates());
+       }
+
+       @Test void a07_templateSuffixDefaultsEmpty() {
+               var r = BasicFreemarkerResource.create().build();
+               assertEquals("", r.getTemplateSuffix());
+       }
+
+       @Test void a08_templateSuffixSetterRoundTrips() {
+               var r = 
BasicFreemarkerResource.create().templateSuffix(".ftlh").build();
+               assertEquals(".ftlh", r.getTemplateSuffix());
+       }
+
+       @Test void a09_templateSuffixNullResetsToDefault() {
+               var r = BasicFreemarkerResource.create()
+                       .templateSuffix(".ftlh")
+                       .templateSuffix(null)
+                       .build();
+               assertEquals(BasicFreemarkerResource.DEFAULT_TEMPLATE_SUFFIX, 
r.getTemplateSuffix());
+       }
+
+       @Test void a10_cacheTemplatesDefaultsTrue() {
+               var r = BasicFreemarkerResource.create().build();
+               assertTrue(r.isCacheTemplates());
+       }
+
+       @Test void a11_cacheTemplatesSetterRoundTrips() {
+               var r = 
BasicFreemarkerResource.create().cacheTemplates(false).build();
+               assertFalse(r.isCacheTemplates());
+       }
+
+       @Test void a12_builderReadersReflectMutations() {
+               var b = BasicFreemarkerResource.create()
+                       .basePath("/views/")
+                       .templateSuffix(".ftl")
+                       .cacheTemplates(false);
+               assertEquals("/views/", b.getBasePath());
+               assertEquals(".ftl", b.getTemplateSuffix());
+               assertFalse(b.isCacheTemplates());
+       }
+
+       @Test void a13_defaultConfigurationIsLazyButFreshlyBuilt() {
+               // buildDefaultConfiguration returns a fresh instance each 
call; the caching is in
+               // resolveConfiguration itself. This test pins the contract 
that the constructor is
+               // idempotent (each call returns a non-null Configuration with 
the expected loader
+               // configured).
+               var r = 
BasicFreemarkerResource.create().basePath("/freemarker-templates/").build();
+               var c1 = r.buildDefaultConfiguration();
+               var c2 = r.buildDefaultConfiguration();
+               assertNotNull(c1);
+               assertNotNull(c2);
+               assertNotSame(c1, c2,
+                       "buildDefaultConfiguration is the constructor — the 
caching lives in resolveConfiguration");
+               assertInstanceOf(Configuration.class, c1);
+               assertNotNull(c1.getTemplateLoader());
+       }
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section B: applyTemplateSuffix helper (idempotent appender)
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void b01_applyTemplateSuffixAppendsWhenMissing() {
+               var r = 
BasicFreemarkerResource.create().templateSuffix(".ftlh").build();
+               assertEquals("hello.ftlh", r.applyTemplateSuffix("hello"));
+       }
+
+       @Test void b02_applyTemplateSuffixIdempotentWhenPresent() {
+               var r = 
BasicFreemarkerResource.create().templateSuffix(".ftlh").build();
+               assertEquals("hello.ftlh", r.applyTemplateSuffix("hello.ftlh"));
+       }
+
+       @Test void b03_applyTemplateSuffixNoOpWhenSuffixEmpty() {
+               var r = BasicFreemarkerResource.create().build();
+               assertEquals("hello", r.applyTemplateSuffix("hello"));
+               assertEquals("hello.ftlh", r.applyTemplateSuffix("hello.ftlh"));
+       }
+
+       @Test void b04_applyTemplateSuffixHandlesMultiSegment() {
+               var r = 
BasicFreemarkerResource.create().templateSuffix(".ftlh").build();
+               assertEquals("admin/dashboard.ftlh", 
r.applyTemplateSuffix("admin/dashboard"));
+               assertEquals("admin/dashboard.ftlh", 
r.applyTemplateSuffix("admin/dashboard.ftlh"));
+       }
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section C: toResourceRoot helper (basePath → FreeMarker classloader 
resource root)
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void c01_toResourceRootKeepsLeadingSlashTrimsTrailing() {
+               assertEquals("/templates", 
BasicFreemarkerResource.toResourceRoot("/templates/"));
+       }
+
+       @Test void c02_toResourceRootAddsLeadingSlash() {
+               assertEquals("/templates", 
BasicFreemarkerResource.toResourceRoot("templates/"));
+       }
+
+       @Test void c03_toResourceRootHandlesNoTrailingSlash() {
+               assertEquals("/templates", 
BasicFreemarkerResource.toResourceRoot("/templates"));
+       }
+
+       @Test void c04_toResourceRootHandlesRootBase() {
+               assertEquals("/", BasicFreemarkerResource.toResourceRoot("/"));
+       }
+
+       @Test void c05_toResourceRootHandlesNullBase() {
+               assertEquals("/", BasicFreemarkerResource.toResourceRoot(null));
+       }
+
+       @Test void c06_toResourceRootHandlesBlankBase() {
+               assertEquals("/", BasicFreemarkerResource.toResourceRoot(""));
+               assertEquals("/", BasicFreemarkerResource.toResourceRoot("   
"));
+       }
+
+       @Test void c07_toResourceRootHandlesMultiSegmentBase() {
+               assertEquals("/a/b/c", 
BasicFreemarkerResource.toResourceRoot("/a/b/c/"));
+       }
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section D: stripBasePath helper
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void d01_stripBasePathRemovesPrefix() {
+               assertEquals("hello",
+                       BasicFreemarkerResource.stripBasePath("/templates/", 
"/templates/hello"));
+       }
+
+       @Test void d02_stripBasePathHandlesMissingTrailingSlash() {
+               assertEquals("hello",
+                       BasicFreemarkerResource.stripBasePath("/templates", 
"/templates/hello"));
+       }
+
+       @Test void d03_stripBasePathHandlesRootBase() {
+               assertEquals("hello",
+                       BasicFreemarkerResource.stripBasePath("/", "/hello"));
+       }
+
+       @Test void d04_stripBasePathHandlesNullBase() {
+               // null base normalizes to "/" (matches the helper's contract).
+               assertEquals("hello",
+                       BasicFreemarkerResource.stripBasePath(null, "/hello"));
+       }
+
+       @Test void d05_stripBasePathHandlesEmptyBase() {
+               assertEquals("hello",
+                       BasicFreemarkerResource.stripBasePath("", "/hello"));
+       }
+
+       @Test void d06_stripBasePathHandlesMultiSegment() {
+               assertEquals("admin/dashboard",
+                       BasicFreemarkerResource.stripBasePath("/templates/", 
"/templates/admin/dashboard"));
+       }
+
+       @Test void d07_stripBasePathThrowsWhenResolvedOutsideBase() {
+               assertThrows(IllegalArgumentException.class,
+                       () -> 
BasicFreemarkerResource.stripBasePath("/templates/", "/other/hello"));
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_MockRest_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_MockRest_Test.java
new file mode 100644
index 0000000000..e0c1fe2296
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_MockRest_Test.java
@@ -0,0 +1,149 @@
+/*
+ * 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 org.apache.juneau.rest.view.freemarker;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * MockRest-level assertions for {@link BasicFreemarkerResource} composition + 
route wiring.
+ *
+ * <p>
+ * Like Thymeleaf and Mustache (and unlike JSP), the Apache FreeMarker engine 
has <b>zero
+ * servlet-container dependencies</b> &mdash; it asks {@link 
freemarker.template.Configuration}
+ * for a {@code Template} and {@code process(dataModel, writer)} writes 
directly to the response
+ * writer. That makes the <i>raw-render path</i> (the mixin's own {@code 
/freemarker/*} handler
+ * that calls {@code cfg.getTemplate(...).process(...)} directly onto the 
response writer) fully
+ * exerciseable under MockRest, which is what this test covers.
+ *
+ * <h5 class='section'>Deferred &mdash; typed {@link 
org.apache.juneau.rest.view.View View} return
+ * path:</h5>
+ *
+ * <p>
+ * Verifying that an {@code @RestOp} method returning a {@link FreemarkerView} 
renders through
+ * {@link FreemarkerViewRenderer} requires the renderer to run <b>before</b> 
the default
+ * HTML/JSON/&hellip; serializer processors in the response chain. Today the 
mixin's
+ * {@link Rest#responseProcessors() @Rest(responseProcessors=...)} declaration 
appends to the
+ * chain, so the default HTML serializer wins under MockRest and the {@code 
FreemarkerView} is
+ * serialized as a Juneau-bean HTML table instead of routed through 
FreeMarker. The response-
+ * processor "prepend" mechanism that fixes this is tracked separately (work 
item 96) and blocks
+ * the typed-handler integration matrix for all four sibling view modules (JSP 
/ Thymeleaf /
+ * Mustache / FreeMarker). The typed-handler test surface lights up alongside 
the real-container
+ * coverage once that prereq lands &mdash; tracked as the FreeMarker analog of 
the deferred JSP /
+ * Thymeleaf / Mustache follow-ons.
+ *
+ * <h5 class='figure'>Test resource layout (classpath {@code 
src/test/resources}):</h5>
+ *
+ * <pre>
+ *   /freemarker-templates/hello.ftlh        ← greets "Hello, ${name}!" (used 
by future typed-handler tests)
+ *   /freemarker-templates/about.ftlh        ← static "About Juneau"
+ *   /freemarker-templates/nested/inner.ftlh ← validates multi-segment paths
+ * </pre>
+ *
+ * @since 9.5.0
+ */
+class BasicFreemarkerResource_MockRest_Test extends TestBase {
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Resource A: mixin only — default base path "/" + default 
configuration + no implicit suffix
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Rest(mixins=BasicFreemarkerResource.class)
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @RestGet(path="/items") public String items() { return "items"; 
}
+       }
+
+       private static final MockRestClient ca = 
MockRestClient.buildLax(A.class);
+
+       @Test void a01_hostEndpointStillReachable() throws Exception {
+               ca.get("/items")
+                       .accept("application/json")
+                       .run()
+                       .assertStatus(200)
+                       .assertContent().asString().isContains("items");
+       }
+
+       @Test void a02_freemarkerMountInstalled_rendersTemplate() throws 
Exception {
+               // Default basePath="/" + no templateSuffix → request path is 
taken as-is, FreeMarker's
+               // root-of-classpath loader resolves it directly.
+               ca.get("/freemarker/freemarker-templates/about.ftlh")
+                       .run()
+                       .assertStatus(200)
+                       .assertContent().asString().isContains("About Juneau");
+       }
+
+       @Test void a04_nonMixinPathFallsThrough() throws Exception {
+               ca.get("/does-not-exist")
+                       .run()
+                       .assertStatus(404);
+       }
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Resource B: configured base path "/freemarker-templates/" + 
templateSuffix ".ftlh"
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Rest(mixins=BasicFreemarkerResource.class)
+       public static class B extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @Bean public BasicFreemarkerResource freemarker() {
+                       return BasicFreemarkerResource.create()
+                               .basePath("/freemarker-templates/")
+                               .templateSuffix(".ftlh")
+                               .build();
+               }
+       }
+
+       private static final MockRestClient cb = 
MockRestClient.buildLax(B.class);
+
+       @Test void b01_renderResolvesUnderConfiguredBasePath() throws Exception 
{
+               // basePath = /freemarker-templates/, suffix = .ftlh.
+               // /freemarker/about → cfg.getTemplate("about.ftlh") → resolves
+               // /freemarker-templates/about.ftlh on the classpath.
+               cb.get("/freemarker/about")
+                       .run()
+                       .assertStatus(200)
+                       .assertContent().asString().isContains("About Juneau");
+       }
+
+       @Test void b02_renderResolvesWithExplicitSuffix() throws Exception {
+               // Same template via the explicit-suffix request form; suffix 
is idempotent.
+               cb.get("/freemarker/about.ftlh")
+                       .run()
+                       .assertStatus(200)
+                       .assertContent().asString().isContains("About Juneau");
+       }
+
+       @Test void b03_renderHandlesMultiSegmentPath() throws Exception {
+               cb.get("/freemarker/nested/inner")
+                       .run()
+                       .assertStatus(200)
+                       .assertContent().asString().isContains("inner 
template");
+       }
+
+       @Test void b04_contentTypeDefaultsToHtml() throws Exception {
+               cb.get("/freemarker/about")
+                       .run()
+                       .assertStatus(200)
+                       .assertHeader("Content-Type").isContains("text/html");
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_PathTraversal_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_PathTraversal_Test.java
new file mode 100644
index 0000000000..1cc38bf3a8
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_PathTraversal_Test.java
@@ -0,0 +1,111 @@
+/*
+ * 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 org.apache.juneau.rest.view.freemarker;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Regression tests for path-traversal hardening in
+ * {@link BasicFreemarkerResource#render render(...)}.
+ *
+ * <p>
+ * Mirrors {@code BasicJspResource_PathTraversal_Test},
+ * {@code BasicThymeleafResource_PathTraversal_Test}, and
+ * {@code BasicMustacheResource_PathTraversal_Test}: the handler must reject 
any user-supplied
+ * {@code @Path("/*") String path} that resolves outside the configured {@code 
basePath} with HTTP
+ * 403, via {@link 
org.apache.juneau.commons.utils.FileUtils#resolveVirtualPathSafely
+ * FileUtils.resolveVirtualPathSafely}.
+ *
+ * <p>
+ * MockRest does not URL-normalize request paths the way a real servlet 
container does, so
+ * {@code @Path("/*") String path} receives the raw {@code ..} segments 
&mdash; which is what the
+ * handler-layer boundary check is designed to catch. (Real-container coverage 
is deferred to the
+ * FreeMarker analog of TODO-97 / TODO-107 / TODO-108 that lights up Jetty / 
Spring Boot
+ * end-to-end alongside the JSP, Thymeleaf, and Mustache modules.)
+ *
+ * @since 9.5.0
+ */
+class BasicFreemarkerResource_PathTraversal_Test extends TestBase {
+
+       @Rest(mixins=BasicFreemarkerResource.class)
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+
+               // Register a non-default base path so the boundary check has 
somewhere to stay inside of.
+               @Bean
+               public BasicFreemarkerResource freemarker() {
+                       return BasicFreemarkerResource.create()
+                               .basePath("/freemarker-templates/")
+                               .templateSuffix(".ftlh")
+                               .build();
+               }
+       }
+
+       private static final MockRestClient c = 
MockRestClient.buildLax(A.class);
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Baseline — well-formed requests reach the engine (real template 
renders since FreeMarker
+       // works under MockRest, so we expect 200, not 403).
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void t01_normalAccess_passesBoundaryCheck() throws Exception {
+               var status = c.get("/freemarker/about").run().getStatusCode();
+               assertNotEquals(403, status, "Well-formed request must not be 
rejected by boundary check");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // CWE-22: direct .. traversal of basePath returns 403
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void t02_directTraversal_returns403() throws Exception {
+               var status = 
c.get("/freemarker/../secret").run().getStatusCode();
+               assertEquals(403, status,
+                       "GET /freemarker/../secret must be rejected (escapes 
/freemarker-templates/)");
+       }
+
+       @Test void t03_nestedTraversal_returns403() throws Exception {
+               var status = 
c.get("/freemarker/a/b/../../../secret").run().getStatusCode();
+               assertEquals(403, status, "GET /freemarker/a/b/../../../secret 
must be rejected");
+       }
+
+       @Test void t04_traversalToSibling_returns403() throws Exception {
+               // /freemarker/../views2/foo → after join: 
/freemarker-templates/../views2/foo →
+               // normalized: /views2/foo → does NOT start with 
/freemarker-templates/ → 403.
+               var status = 
c.get("/freemarker/../views2/foo").run().getStatusCode();
+               assertEquals(403, status, "Traversal to a sibling 
base-path-prefix must be rejected");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // URL-encoded traversal handling
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void t05_urlEncodedTraversal_doesNotEscape() throws Exception {
+               // Behavior depends on whether HttpClient / MockRest 
URL-decodes %2e%2e before the
+               // handler sees the path. Either way, the response MUST NOT be 
200 — that would mean the
+               // outside file got rendered.
+               var status = 
c.get("/freemarker/%2e%2e/secret").run().getStatusCode();
+               assertNotEquals(200, status,
+                       "URL-encoded traversal must not return 200. Status was: 
" + status);
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_SvlPathOverride_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_SvlPathOverride_Test.java
new file mode 100644
index 0000000000..bd35701489
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/BasicFreemarkerResource_SvlPathOverride_Test.java
@@ -0,0 +1,66 @@
+/*
+ * 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 org.apache.juneau.rest.view.freemarker;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates the {@code ${juneau.freemarker.path:freemarker}} SVL override on
+ * {@link BasicFreemarkerResource}.
+ *
+ * <p>
+ * Apache FreeMarker renders directly to the response writer under MockRest 
(no servlet-container
+ * deps), so we can verify the SVL substitution moved the mount point by 
asserting that the
+ * default {@code /freemarker/*} no longer matches (404) while the overridden 
prefix does (200
+ * with the rendered template body).
+ *
+ * <p>
+ * Uses fresh inner-class resources because {@link MockRestClient} caches 
{@link RestContext} per
+ * resource class &mdash; SVL substitution is captured at context-construction 
time.
+ *
+ * @since 9.5.0
+ */
+class BasicFreemarkerResource_SvlPathOverride_Test extends TestBase {
+
+       @Rest(mixins=BasicFreemarkerResource.class)
+       public static class A01_OverridePath extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test void a01_svlOverrideChangesPath() throws Exception {
+               var key = "juneau.freemarker.path";
+               var prev = System.getProperty(key);
+               System.setProperty(key, "views");
+               try {
+                       var c = MockRestClient.buildLax(A01_OverridePath.class);
+
+                       
c.get("/freemarker/freemarker-templates/about.ftlh").run().assertStatus(404);
+
+                       c.get("/views/freemarker-templates/about.ftlh")
+                               .run()
+                               .assertStatus(200)
+                               .assertContent().asString().isContains("About 
Juneau");
+               } finally {
+                       if (prev == null) System.clearProperty(key);
+                       else System.setProperty(key, prev);
+               }
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer_Test.java
new file mode 100644
index 0000000000..bf95a2f892
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer_Test.java
@@ -0,0 +1,60 @@
+/*
+ * 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 org.apache.juneau.rest.view.freemarker;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link FreemarkerViewRenderer}.
+ *
+ * <p>
+ * Covers the diagnostic-message constant + default content-type. The full
+ * {@link FreemarkerViewRenderer#process process(...)} dispatch path is 
exercised end-to-end by
+ * the MockRest-flavored tests in this package (full Jetty / Spring Boot 
real-container coverage
+ * is deferred to a follow-on TODO, the FreeMarker analog of TODO-97 for JSP, 
TODO-107 for
+ * Thymeleaf, and TODO-108 for Mustache).
+ *
+ * @since 9.5.0
+ */
+class FreemarkerViewRenderer_Test extends TestBase {
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section A: NO_ENGINE_DIAGNOSTIC text
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void a01_diagnosticNamesFreemarkerCore() {
+               
assertTrue(FreemarkerViewRenderer.NO_ENGINE_DIAGNOSTIC.contains("org.freemarker:freemarker"),
+                       "Diagnostic must name the FreeMarker engine core 
dependency");
+       }
+
+       @Test void a02_diagnosticCallsOutSpringBootStarter() {
+               
assertTrue(FreemarkerViewRenderer.NO_ENGINE_DIAGNOSTIC.contains("spring-boot-starter-freemarker"),
+                       "Diagnostic must mention the Spring Boot starter 
alternative");
+       }
+
+       @Test void a03_diagnosticLinksToDocs() {
+               
assertTrue(FreemarkerViewRenderer.NO_ENGINE_DIAGNOSTIC.contains("FreemarkerViewSupport"),
+                       "Diagnostic must link to the FreeMarker topic page");
+       }
+
+       @Test void a04_defaultContentTypeIsHtmlUtf8() {
+               assertEquals("text/html;charset=UTF-8", 
FreemarkerViewRenderer.DEFAULT_CONTENT_TYPE);
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/FreemarkerView_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/FreemarkerView_Test.java
new file mode 100644
index 0000000000..a9198ee394
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/freemarker/FreemarkerView_Test.java
@@ -0,0 +1,228 @@
+/*
+ * 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 org.apache.juneau.rest.view.freemarker;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.view.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link FreemarkerView}.
+ *
+ * <p>
+ * Covers the immutable-value-class contract: {@link FreemarkerView#of(String) 
of(...)}
+ * validation, fluent {@link FreemarkerView#attr(String, Object) attr(...)} /
+ * {@link FreemarkerView#attrs(Map) attrs(...)} /
+ * {@link FreemarkerView#header(String, String) header(...)} chaining, and the
+ * {@link View}-interface surface ({@code getTemplateName} / {@code 
getAttributes} /
+ * {@code getResponseHeaders}).
+ *
+ * @since 9.5.0
+ */
+class FreemarkerView_Test extends TestBase {
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section A: factory + invariants
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void a01_ofProducesViewWithTemplateAndEmptyMaps() {
+               var v = FreemarkerView.of("hello.ftlh");
+               assertEquals("hello.ftlh", v.getTemplateName());
+               assertTrue(v.getAttributes().isEmpty(), "attributes must be 
empty");
+               assertTrue(v.getResponseHeaders().isEmpty(), "responseHeaders 
must be empty");
+       }
+
+       @Test void a02_ofRejectsNullTemplate() {
+               assertThrows(IllegalArgumentException.class, () -> 
FreemarkerView.of(null));
+       }
+
+       @Test void a03_ofRejectsBlankTemplate() {
+               assertThrows(IllegalArgumentException.class, () -> 
FreemarkerView.of(""));
+               assertThrows(IllegalArgumentException.class, () -> 
FreemarkerView.of("   "));
+       }
+
+       @Test void a04_isViewInstance() {
+               // Verifies the type relationship that ResponseProcessor 
pattern-matches on.
+               View v = FreemarkerView.of("x");
+               assertNotNull(v);
+       }
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section B: attr(...)
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void b01_attrAddsBindingOnNewInstance() {
+               var v0 = FreemarkerView.of("hello");
+               var v1 = v0.attr("name", "Bob");
+
+               assertNotSame(v0, v1, "attr() must return a new instance");
+               assertTrue(v0.getAttributes().isEmpty(), "original must remain 
unchanged");
+               assertEquals("Bob", v1.getAttributes().get("name"));
+       }
+
+       @Test void b02_attrChainsMultipleBindings() {
+               var v = FreemarkerView.of("hello")
+                       .attr("name", "Bob")
+                       .attr("age", 42)
+                       .attr("active", Boolean.TRUE);
+
+               assertEquals("Bob", v.getAttributes().get("name"));
+               assertEquals(42, v.getAttributes().get("age"));
+               assertEquals(Boolean.TRUE, v.getAttributes().get("active"));
+               assertEquals(3, v.getAttributes().size());
+       }
+
+       @Test void b03_attrReplacesExistingBinding() {
+               var v = FreemarkerView.of("hello")
+                       .attr("name", "Bob")
+                       .attr("name", "Alice");
+
+               assertEquals("Alice", v.getAttributes().get("name"));
+               assertEquals(1, v.getAttributes().size());
+       }
+
+       @Test void b04_attrRejectsNullValue() {
+               // FreeMarker's default null-handling on missing/null bindings 
is an error or empty
+               // rendering depending on configuration; reject at build-time 
so the mistake surfaces at
+               // the call site (matches the Thymeleaf and Mustache bridge 
precedents).
+               assertThrows(IllegalArgumentException.class,
+                       () -> FreemarkerView.of("hello").attr("missing", null));
+       }
+
+       @Test void b05_attrRejectsNullKey() {
+               assertThrows(IllegalArgumentException.class,
+                       () -> FreemarkerView.of("hello").attr(null, "v"));
+       }
+
+       @Test void b06_attrRejectsBlankKey() {
+               assertThrows(IllegalArgumentException.class,
+                       () -> FreemarkerView.of("hello").attr("", "v"));
+               assertThrows(IllegalArgumentException.class,
+                       () -> FreemarkerView.of("hello").attr("   ", "v"));
+       }
+
+       @Test void b07_attributesMapIsImmutable() {
+               var v = FreemarkerView.of("hello").attr("name", "Bob");
+               assertThrows(UnsupportedOperationException.class,
+                       () -> v.getAttributes().put("name", "Alice"));
+       }
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section C: attrs(...)
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void c01_attrsMergesMap() {
+               var v = FreemarkerView.of("hello")
+                       .attrs(Map.of("a", 1, "b", 2));
+               assertEquals(1, v.getAttributes().get("a"));
+               assertEquals(2, v.getAttributes().get("b"));
+       }
+
+       @Test void c02_attrsNullReturnsSameInstance() {
+               var v0 = FreemarkerView.of("hello");
+               var v1 = v0.attrs(null);
+               assertSame(v0, v1, "attrs(null) is a no-op");
+       }
+
+       @Test void c03_attrsEmptyReturnsSameInstance() {
+               var v0 = FreemarkerView.of("hello");
+               var v1 = v0.attrs(Map.of());
+               assertSame(v0, v1, "attrs(emptyMap) is a no-op");
+       }
+
+       @Test void c04_attrsRejectsBlankKey() {
+               var m = new LinkedHashMap<String, Object>();
+               m.put("ok", 1);
+               m.put("", 2);
+               assertThrows(IllegalArgumentException.class,
+                       () -> FreemarkerView.of("hello").attrs(m));
+       }
+
+       @Test void c05_attrsRejectsNullValue() {
+               var m = new LinkedHashMap<String, Object>();
+               m.put("ok", 1);
+               m.put("bad", null);
+               assertThrows(IllegalArgumentException.class,
+                       () -> FreemarkerView.of("hello").attrs(m));
+       }
+
+       @Test void c06_attrsOverridesExistingBindings() {
+               var v = FreemarkerView.of("hello")
+                       .attr("a", 1)
+                       .attrs(Map.of("a", 99, "b", 2));
+               assertEquals(99, v.getAttributes().get("a"));
+               assertEquals(2, v.getAttributes().get("b"));
+       }
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section D: header(...)
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void d01_headerAddsResponseHeader() {
+               var v = FreemarkerView.of("hello")
+                       .header("Content-Type", "text/html; charset=UTF-8");
+               assertEquals("text/html; charset=UTF-8", 
v.getResponseHeaders().get("Content-Type"));
+       }
+
+       @Test void d02_headerChainsMultipleHeaders() {
+               var v = FreemarkerView.of("hello")
+                       .header("Content-Type", "text/html; charset=UTF-8")
+                       .header("Cache-Control", "no-store");
+               assertEquals(2, v.getResponseHeaders().size());
+       }
+
+       @Test void d03_headerRejectsBlankName() {
+               assertThrows(IllegalArgumentException.class,
+                       () -> FreemarkerView.of("hello").header("", "v"));
+               assertThrows(IllegalArgumentException.class,
+                       () -> FreemarkerView.of("hello").header(null, "v"));
+       }
+
+       @Test void d04_headerRejectsNullValue() {
+               assertThrows(IllegalArgumentException.class,
+                       () -> FreemarkerView.of("hello").header("X", null));
+       }
+
+       @Test void d05_headerReturnsNewInstance() {
+               var v0 = FreemarkerView.of("hello");
+               var v1 = v0.header("X", "1");
+               assertNotSame(v0, v1);
+               assertTrue(v0.getResponseHeaders().isEmpty());
+       }
+
+       @Test void d06_responseHeadersMapIsImmutable() {
+               var v = FreemarkerView.of("hello").header("X", "1");
+               assertThrows(UnsupportedOperationException.class,
+                       () -> v.getResponseHeaders().put("Y", "2"));
+       }
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section E: toString + misc
+        * 
----------------------------------------------------------------------------------------
 */
+
+       @Test void e01_toStringIncludesTemplateName() {
+               var v = FreemarkerView.of("hello").attr("k", "v").header("H", 
"1");
+               var s = v.toString();
+               assertTrue(s.contains("hello"), "toString must include template 
name: " + s);
+               assertTrue(s.contains("k"), "toString must include attribute 
keys: " + s);
+               assertTrue(s.contains("H"), "toString must include header keys: 
" + s);
+       }
+}
diff --git a/juneau-utest/src/test/resources/freemarker-templates/about.ftlh 
b/juneau-utest/src/test/resources/freemarker-templates/about.ftlh
new file mode 100644
index 0000000000..5b144772b3
--- /dev/null
+++ b/juneau-utest/src/test/resources/freemarker-templates/about.ftlh
@@ -0,0 +1,22 @@
+<#--
+  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.
+--><!DOCTYPE html>
+<html>
+<head><title>About</title></head>
+<body>
+<p>About Juneau</p>
+</body>
+</html>
diff --git a/juneau-utest/src/test/resources/freemarker-templates/hello.ftlh 
b/juneau-utest/src/test/resources/freemarker-templates/hello.ftlh
new file mode 100644
index 0000000000..214acd4aa9
--- /dev/null
+++ b/juneau-utest/src/test/resources/freemarker-templates/hello.ftlh
@@ -0,0 +1,22 @@
+<#--
+  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.
+--><!DOCTYPE html>
+<html>
+<head><title>Hello</title></head>
+<body>
+<p>Hello, ${name}!</p>
+</body>
+</html>
diff --git 
a/juneau-utest/src/test/resources/freemarker-templates/nested/inner.ftlh 
b/juneau-utest/src/test/resources/freemarker-templates/nested/inner.ftlh
new file mode 100644
index 0000000000..76888cbf0e
--- /dev/null
+++ b/juneau-utest/src/test/resources/freemarker-templates/nested/inner.ftlh
@@ -0,0 +1,22 @@
+<#--
+  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.
+--><!DOCTYPE html>
+<html>
+<head><title>Inner</title></head>
+<body>
+<p>This is the inner template.</p>
+</body>
+</html>


Reply via email to