You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/05/26 16:09:02 UTC

(juneau) branch master updated: feat(rest): new juneau-rest-server-view-thymeleaf module (TODO-82)

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 5c31fcea43 feat(rest): new juneau-rest-server-view-thymeleaf module (TODO-82)
5c31fcea43 is described below

commit 5c31fcea43d2ea3495fd94443cc0e454798bb4b0
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue May 26 12:08:57 2026 -0400

    feat(rest): new juneau-rest-server-view-thymeleaf module (TODO-82)
---
 juneau-distrib/pom.xml                             |  25 ++
 .../juneau-rest-server-view-thymeleaf/pom.xml      | 123 ++++++
 .../view/thymeleaf/BasicThymeleafResource.java     | 491 +++++++++++++++++++++
 .../juneau/rest/view/thymeleaf/ThymeleafView.java  | 198 +++++++++
 .../rest/view/thymeleaf/ThymeleafViewRenderer.java | 141 ++++++
 .../juneau/rest/view/thymeleaf/package-info.java   |  91 ++++
 juneau-rest/pom.xml                                |   1 +
 juneau-utest/pom.xml                               |  25 ++
 .../BasicThymeleafResource_Builder_Test.java       | 175 ++++++++
 .../BasicThymeleafResource_MockRest_Test.java      | 144 ++++++
 .../BasicThymeleafResource_PathTraversal_Test.java | 105 +++++
 ...asicThymeleafResource_SvlPathOverride_Test.java |  66 +++
 .../view/thymeleaf/ThymeleafViewRenderer_Test.java | 105 +++++
 .../rest/view/thymeleaf/ThymeleafView_Test.java    | 226 ++++++++++
 .../src/test/resources/templates/about.html        |  22 +
 .../src/test/resources/templates/hello.html        |  22 +
 .../src/test/resources/templates/nested/inner.html |  22 +
 17 files changed, 1982 insertions(+)

diff --git a/juneau-distrib/pom.xml b/juneau-distrib/pom.xml
index e496750969..b9b024be6f 100644
--- a/juneau-distrib/pom.xml
+++ b/juneau-distrib/pom.xml
@@ -533,6 +533,31 @@
 									<destFileName>org.apache.juneau.rest.server.view.jsp_${project.version}.jar</destFileName>
 								</artifactItem>
 
+								<!-- juneau-rest-server-view-thymeleaf -->
+								<artifactItem>
+									<outputDirectory>${project.build.directory}/src/lib</outputDirectory>
+									<groupId>org.apache.juneau</groupId>
+									<artifactId>juneau-rest-server-view-thymeleaf</artifactId>
+									<version>${project.version}</version>
+									<type>jar</type>
+									<classifier>sources</classifier>
+									<destFileName>apache-juneau-rest-server-view-thymeleaf-${project.version}-sources.jar</destFileName>
+								</artifactItem>
+								<artifactItem>
+									<outputDirectory>${project.build.directory}/bin/lib</outputDirectory>
+									<groupId>org.apache.juneau</groupId>
+									<artifactId>juneau-rest-server-view-thymeleaf</artifactId>
+									<version>${project.version}</version>
+									<destFileName>apache-juneau-rest-server-view-thymeleaf-${project.version}.jar</destFileName>
+								</artifactItem>
+								<artifactItem>
+									<outputDirectory>${project.build.directory}/bin/osgi</outputDirectory>
+									<groupId>org.apache.juneau</groupId>
+									<artifactId>juneau-rest-server-view-thymeleaf</artifactId>
+									<version>${project.version}</version>
+									<destFileName>org.apache.juneau.rest.server.view.thymeleaf_${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-thymeleaf/pom.xml b/juneau-rest/juneau-rest-server-view-thymeleaf/pom.xml
new file mode 100644
index 0000000000..7deee797c7
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-view-thymeleaf/pom.xml
@@ -0,0 +1,123 @@
+<?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-thymeleaf</artifactId>
+	<name>Apache Juneau REST Server View — Thymeleaf</name>
+	<description>Apache Juneau REST Server - Thymeleaf view-rendering bridge (BasicThymeleafResource mixin + ThymeleafViewRenderer).</description>
+	<packaging>bundle</packaging>
+
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<thymeleaf.version>3.1.3.RELEASE</thymeleaf.version>
+	</properties>
+
+	<!--
+		Engine-agnostic POM stance (mirrors juneau-rest-server-view-jsp resolved decision #2).
+
+		The bridge module declares ONLY the Thymeleaf core API in `provided` scope. NO concrete
+		Thymeleaf engine dependency leaks into consumer projects through this module. Consumers
+		add the engine matching their deployment:
+			- Spring Boot: `spring-boot-starter-thymeleaf` (transitively brings thymeleaf +
+			  thymeleaf-spring6 + autoconfigured SpringTemplateEngine bean).
+			- Juneau microservice / Jetty: add `org.thymeleaf:thymeleaf` directly; the bridge
+			  builds a default `TemplateEngine` lazily at first use if no bean is registered.
+			- Custom: user supplies their own `TemplateEngine` `@Bean` with whatever resolvers
+			  / dialects / template modes they want.
+		See "Choosing a TemplateEngine" matrix in ThymeleafViewSupport.md for the full guidance.
+	-->
+	<dependencies>
+		<dependency>
+			<groupId>org.apache.juneau</groupId>
+			<artifactId>juneau-rest-server</artifactId>
+			<version>${project.version}</version>
+		</dependency>
+		<dependency>
+			<groupId>org.thymeleaf</groupId>
+			<artifactId>thymeleaf</artifactId>
+			<version>${thymeleaf.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-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource.java b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource.java
new file mode 100644
index 0000000000..b5623f2e7d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource.java
@@ -0,0 +1,491 @@
+/*
+ * 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.thymeleaf;
+
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.io.*;
+
+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.*;
+import org.thymeleaf.*;
+import org.thymeleaf.templatemode.*;
+import org.thymeleaf.templateresolver.*;
+
+/**
+ * Mixin that wires Thymeleaf view-rendering onto any Juneau REST resource.
+ *
+ * <p>
+ * Compose into a host resource via
+ * {@link Rest#mixins() @Rest(mixins=BasicThymeleafResource.class)}; the host then:
+ *
+ * <ol class='spaced-list'>
+ * 	<li>Gains a default mount at {@code /thymeleaf/*} that serves raw {@code .html} templates from
+ * 		the importer's classpath by asking the configured
+ * 		{@link org.thymeleaf.TemplateEngine TemplateEngine} to render them with the current
+ * 		request's locale and attributes available to the template.
+ * 	<li>Picks up {@link ThymeleafViewRenderer} automatically via the mixin's
+ * 		{@link Rest#responseProcessors() @Rest(responseProcessors=...)} declaration, so
+ * 		{@code @RestOp}-method return values of type {@link ThymeleafView} render through the
+ * 		Thymeleaf engine without any additional wiring.
+ * </ol>
+ *
+ * <h5 class='figure'>Composition example (microservice):</h5>
+ *
+ * <p class='bjava'>
+ * 	<ja>@Rest</ja>(path=<js>"/app"</js>, mixins=BasicThymeleafResource.<jk>class</jk>)
+ * 	<jk>public class</jk> AppResource <jk>extends</jk> RestServlet {
+ *
+ * 		<ja>@Bean</ja> BasicThymeleafResource thymeleaf() {
+ * 			<jk>return</jk> BasicThymeleafResource.<jsm>create</jsm>()
+ * 				.basePath(<js>"/templates/"</js>)
+ * 				.build();
+ * 		}
+ *
+ * 		<ja>@RestGet</ja>(<js>"/hello/{name}"</js>)
+ * 		<jk>public</jk> View hello(<ja>@Path</ja> String <jv>name</jv>) {
+ * 			<jk>return</jk> ThymeleafView.<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 /thymeleaf/*} can be overridden via the SVL variable
+ * {@code ${juneau.thymeleaf.path:thymeleaf}} &mdash; set via system property
+ * ({@code -Djuneau.thymeleaf.path=views}), environment variable
+ * ({@code JUNEAU_THYMELEAF_PATH=views}), or {@code Config} key
+ * ({@code juneau.thymeleaf.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.thymeleaf.path:thymeleaf}/*")} 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 TemplateEngine:</h5>
+ *
+ * <p>
+ * The {@code juneau-rest-server-view-thymeleaf} module ships <b>only</b> the Thymeleaf core API in
+ * {@code provided} scope. <b>No engine bean is bundled.</b> The bridge resolves a
+ * {@link org.thymeleaf.TemplateEngine TemplateEngine} at first use from three sources, in order:
+ *
+ * <ul class='spaced-list'>
+ * 	<li><b>Spring Boot autoconfig:</b> {@code spring-boot-starter-thymeleaf} contributes an
+ * 		autoconfigured {@code SpringTemplateEngine} (which extends {@code TemplateEngine}); the
+ * 		bridge picks it up via {@code BeanStore.getBean(TemplateEngine.class)}. No further wiring
+ * 		needed.
+ * 	<li><b>User-supplied bean:</b> register a {@code @Bean TemplateEngine} (Spring) or
+ * 		{@code BasicBeanStore.put(TemplateEngine.class, engine)} (microservice). Allows full
+ * 		control of resolvers, dialects, and template modes.
+ * 	<li><b>Bridge default:</b> when nothing is registered, the bridge constructs a default
+ * 		{@link org.thymeleaf.TemplateEngine TemplateEngine} with a single
+ * 		{@link ClassLoaderTemplateResolver} configured with {@code prefix=basePath},
+ * 		{@code suffix=".html"}, {@code templateMode=HTML}, and {@code cacheable=cacheTemplates}
+ * 		(default {@code true} &mdash; production-safe; set {@code cacheTemplates(false)} on the
+ * 		builder for hot-reload during development).
+ * </ul>
+ *
+ * <p>
+ * When no Thymeleaf engine is on the classpath, the renderer surfaces
+ * {@link ThymeleafViewRenderer#NO_ENGINE_DIAGNOSTIC} naming the missing dependency.
+ *
+ * <h5 class='section'>Multiple base paths:</h5>
+ *
+ * <p>
+ * A host with {@code /templates/} and {@code /admin/templates/} template trees registers two
+ * {@code BasicThymeleafResource} beans, each mounted via a subclass declaring its own op-level
+ * {@code @RestGet(path=...)} override and its own {@code basePath}. See the
+ * {@code BasicThymeleafResource_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 ThymeleafView}
+ * 	<li class='jc'>{@link ThymeleafViewRenderer}
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/ThymeleafViewSupport">Thymeleaf 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={ThymeleafViewRenderer.class}
+)
+public class BasicThymeleafResource {
+
+	/** Default base path applied when no {@link Builder#basePath(String)} call has been made. */
+	public static final String DEFAULT_BASE_PATH = "/";
+
+	/** Default template-cache flag &mdash; production-safe; opt out per-builder for dev. */
+	public static final boolean DEFAULT_CACHE_TEMPLATES = true;
+
+	/** Default template mode for the bridge's fallback engine. */
+	public static final TemplateMode DEFAULT_TEMPLATE_MODE = TemplateMode.HTML;
+
+	private final String basePath;
+	private final boolean cacheTemplates;
+	private final TemplateMode templateMode;
+
+	// Lazy bridge-default engine. Built on first call to resolveTemplateEngine(...) when no
+	// TemplateEngine bean is registered in the request's BeanStore. Volatile so the
+	// double-checked-locking idiom is safe under concurrent first-request load.
+	private volatile TemplateEngine defaultEngine;
+
+	/**
+	 * 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_CACHE_TEMPLATES} for the cache flag, and {@link #DEFAULT_TEMPLATE_MODE} for
+	 * the template mode.
+	 *
+	 * <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 BasicThymeleafResource} bean.
+	 */
+	public BasicThymeleafResource() {
+		this(create());
+	}
+
+	/**
+	 * Builder constructor.
+	 *
+	 * @param builder The builder. Must not be {@code null}.
+	 */
+	protected BasicThymeleafResource(Builder builder) {
+		basePath = builder.basePath;
+		cacheTemplates = builder.cacheTemplates;
+		templateMode = builder.templateMode;
+	}
+
+	/**
+	 * Returns the base path under which template resources are resolved.
+	 *
+	 * <p>
+	 * Used as the {@link ClassLoaderTemplateResolver#setPrefix(String) prefix} of the bridge's
+	 * default engine, 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 whether the bridge's default engine caches resolved templates.
+	 *
+	 * @return The cache flag. Defaults to {@link #DEFAULT_CACHE_TEMPLATES}.
+	 */
+	public boolean isCacheTemplates() {
+		return cacheTemplates;
+	}
+
+	/**
+	 * Returns the template mode used by the bridge's default engine.
+	 *
+	 * @return The template mode. Defaults to {@link #DEFAULT_TEMPLATE_MODE}.
+	 */
+	public TemplateMode getTemplateMode() {
+		return templateMode;
+	}
+
+	/**
+	 * Resolves the active {@link TemplateEngine}.
+	 *
+	 * <p>
+	 * Lookup order:
+	 * <ol class='spaced-list'>
+	 * 	<li>{@code req.getContext().getBeanStore().getBean(TemplateEngine.class)} &mdash; covers
+	 * 		Spring Boot autoconfig (`SpringTemplateEngine` is-a `TemplateEngine`) and any
+	 * 		user-supplied bean.
+	 * 	<li>Lazy bridge default &mdash; constructed on first call when no engine bean is
+	 * 		registered. Carries a single {@link ClassLoaderTemplateResolver} keyed off
+	 * 		({@link #basePath}, {@code .html}, {@link #templateMode}, {@link #cacheTemplates}).
+	 * </ol>
+	 *
+	 * @param req The current REST request.
+	 * @return The active template engine. Never {@code null}.
+	 */
+	public TemplateEngine resolveTemplateEngine(RestRequest req) {
+		var bean = req.getContext().getBeanStore().getBean(TemplateEngine.class);
+		if (bean.isPresent())
+			return bean.get();
+		var local = defaultEngine;
+		if (local == null) {
+			synchronized (this) {
+				local = defaultEngine;
+				if (local == null) {
+					local = buildDefaultEngine();
+					defaultEngine = local;
+				}
+			}
+		}
+		return local;
+	}
+
+	/**
+	 * Constructs the bridge-default {@link TemplateEngine}.
+	 *
+	 * <p>
+	 * Single {@link ClassLoaderTemplateResolver} with {@code prefix=basePath},
+	 * {@code suffix=".html"}, {@code templateMode=templateMode}, and
+	 * {@code cacheable=cacheTemplates}. Subclasses may override to plug in custom resolvers /
+	 * dialects without registering a separate {@code @Bean TemplateEngine}.
+	 *
+	 * @return A new {@link TemplateEngine} instance.
+	 */
+	protected TemplateEngine buildDefaultEngine() {
+		var resolver = new ClassLoaderTemplateResolver();
+		resolver.setPrefix(basePath);
+		resolver.setSuffix(".html");
+		resolver.setTemplateMode(templateMode);
+		resolver.setCacheable(cacheTemplates);
+		resolver.setCharacterEncoding("UTF-8");
+		var engine = new TemplateEngine();
+		engine.setTemplateResolver(resolver);
+		return engine;
+	}
+
+	/**
+	 * [GET /thymeleaf/*] &mdash; render a raw template through the Thymeleaf engine.
+	 *
+	 * <p>
+	 * The {@code @Path("/*") String path} captures the multi-segment trailing remainder (e.g. a
+	 * request for {@code /thymeleaf/about} matches the mount with {@code path = "about"}; a
+	 * request for {@code /thymeleaf/admin/dashboard.html} matches with
+	 * {@code path = "admin/dashboard.html"}). Behavior:
+	 *
+	 * <ol class='spaced-list'>
+	 * 	<li>Strip any trailing {@code .html} extension (the engine resolver re-adds it).
+	 * 	<li>Validate the resolved {@code <basePath><path>} via
+	 * 		{@link FileUtils#resolveVirtualPathSafely(String, String)} &mdash; reject any
+	 * 		{@code ..} traversal with HTTP 403.
+	 * 	<li>Ask the active {@link TemplateEngine} to render the template directly onto the
+	 * 		response writer with the request's locale as the {@link org.thymeleaf.context.Context
+	 * 		Context}'s locale; request attributes and parameters are not auto-bound (callers who
+	 * 		want attributes use {@link ThymeleafView} from a typed handler instead).
+	 * </ol>
+	 *
+	 * <p>
+	 * Missing template surfaces as the engine's own {@code TemplateInputException} (HTTP 500 from
+	 * the renderer's catch-all); missing engine surfaces as
+	 * {@link ThymeleafViewRenderer#NO_ENGINE_DIAGNOSTIC}.
+	 *
+	 * @param path The trailing path segment after {@code /thymeleaf/} (the template name relative
+	 * 	to the configured {@link #getBasePath() base path}; trailing {@code .html} stripped).
+	 * @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.thymeleaf.path:thymeleaf}/*",
+		summary="Thymeleaf view",
+		description="Render a raw .html Thymeleaf template under the configured base path.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public void render(@Path("/*") String path, RestRequest req, RestResponse res)
+			throws IOException, BasicHttpException {
+
+		// The engine resolver re-adds the .html suffix; strip if present so callers can request
+		// /thymeleaf/hello, /thymeleaf/hello.html, or /thymeleaf/admin/dashboard.html uniformly.
+		var template = (path == null) ? "" : path;
+		if (template.endsWith(".html"))
+			template = template.substring(0, template.length() - ".html".length());
+
+		// 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 engine-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.");
+		}
+
+		try {
+			var engine = resolveTemplateEngine(req);
+			var ctx = new org.thymeleaf.context.Context(req.getLocale());
+			if (! res.containsHeader("Content-Type"))
+				res.setHeader("Content-Type", ThymeleafViewRenderer.DEFAULT_CONTENT_TYPE);
+			engine.process(safeTemplate, ctx, res.getWriter());
+		} catch (LinkageError ex) {
+			throw new InternalServerError(ex, ThymeleafViewRenderer.NO_ENGINE_DIAGNOSTIC);
+		} catch (IOException ex) {
+			throw ex;
+		} catch (BasicHttpException ex) {
+			throw ex;
+		} catch (Exception ex) {
+			throw new InternalServerError(ex, "Thymeleaf render failed for ''{0}''", safeTemplate);
+		}
+	}
+
+	/**
+	 * Strips the normalized {@code basePath} prefix from a {@code resolveVirtualPathSafely}
+	 * result so the leftover string is engine-relative (the resolver re-adds the configured
+	 * prefix + suffix).
+	 *
+	 * <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 engine-relative template name (e.g. {@code "hello"} for base
+	 * 	{@code "/templates/"} and resolved {@code "/templates/hello"}).
+	 */
+	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 BasicThymeleafResource}.
+	 */
+	public static class Builder {
+
+		private String basePath = DEFAULT_BASE_PATH;
+		private boolean cacheTemplates = DEFAULT_CACHE_TEMPLATES;
+		private TemplateMode templateMode = DEFAULT_TEMPLATE_MODE;
+
+		/** Constructor &mdash; package access for {@link BasicThymeleafResource#create()}. */
+		protected Builder() {}
+
+		/**
+		 * Sets the classpath base path under which template resources are resolved.
+		 *
+		 * <p>
+		 * Defaults to {@link BasicThymeleafResource#DEFAULT_BASE_PATH "/"}. A typical configured
+		 * value is {@code "/templates/"} &mdash; the Spring-Boot-compatible layout convention.
+		 *
+		 * @param value The base path. {@code null} or blank values reset to the default
+		 * 	{@link BasicThymeleafResource#DEFAULT_BASE_PATH}.
+		 * @return This object.
+		 */
+		public Builder basePath(String value) {
+			basePath = (value == null || value.isBlank()) ? DEFAULT_BASE_PATH : value;
+			return this;
+		}
+
+		/**
+		 * Sets whether the bridge's default engine caches resolved templates.
+		 *
+		 * <p>
+		 * Defaults to {@code true} (production-safe). Set to {@code false} during development to
+		 * pick up edits without restarting the server. This setting only applies to the bridge's
+		 * fallback engine; if a {@link TemplateEngine} bean is registered, its own caching
+		 * configuration is honored.
+		 *
+		 * @param value The new value.
+		 * @return This object.
+		 */
+		public Builder cacheTemplates(boolean value) {
+			cacheTemplates = value;
+			return this;
+		}
+
+		/**
+		 * Sets the template mode used by the bridge's default engine.
+		 *
+		 * <p>
+		 * Defaults to {@link TemplateMode#HTML HTML}. Other modes (`XML`, `TEXT`, `JAVASCRIPT`,
+		 * `CSS`, `RAW`) work if the user supplies templates in that mode; engines that want
+		 * mixed-mode support should register a custom {@code @Bean TemplateEngine} with multiple
+		 * resolvers instead.
+		 *
+		 * @param value The new value. {@code null} resets to {@link #DEFAULT_TEMPLATE_MODE}.
+		 * @return This object.
+		 */
+		public Builder templateMode(TemplateMode value) {
+			templateMode = (value == null) ? DEFAULT_TEMPLATE_MODE : 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 cache-templates setting (test/inspection helper).
+		 *
+		 * @return The cache flag.
+		 */
+		public boolean isCacheTemplates() {
+			return cacheTemplates;
+		}
+
+		/**
+		 * Reads the current template-mode setting (test/inspection helper).
+		 *
+		 * @return The template mode. Never {@code null}.
+		 */
+		public TemplateMode getTemplateMode() {
+			return templateMode;
+		}
+
+		/**
+		 * Builds the {@link BasicThymeleafResource}.
+		 *
+		 * @return A new {@link BasicThymeleafResource} instance.
+		 */
+		public BasicThymeleafResource build() {
+			if (basePath == null)
+				throw illegalArg("basePath must not be null");
+			return new BasicThymeleafResource(this);
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafView.java b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafView.java
new file mode 100644
index 0000000000..1270fae79e
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafView.java
@@ -0,0 +1,198 @@
+/*
+ * 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.thymeleaf;
+
+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 a Thymeleaf template.
+ *
+ * <p>
+ * Companion to {@link BasicThymeleafResource} and {@link ThymeleafViewRenderer}: the mixin sets up
+ * the {@code /thymeleaf/*} mount and registers the renderer; the renderer detects
+ * {@code ThymeleafView} returns in the response-processor chain and asks the configured
+ * {@code org.thymeleaf.TemplateEngine} to {@code process(templateName, context, 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> ThymeleafView.<jsm>of</jsm>(<js>"hello"</js>)
+ * 			.attr(<js>"name"</js>, <jv>name</jv>)
+ * 			.attr(<js>"ts"</js>, Instant.<jsm>now</jsm>());
+ * 	}
+ * </p>
+ *
+ * <p>
+ * The template name is engine-relative &mdash; the configured
+ * {@code ClassLoaderTemplateResolver} (or whatever resolver the active
+ * {@code org.thymeleaf.TemplateEngine} carries) prepends its own prefix and appends its own suffix
+ * (typically {@code .html}). With the bridge's default engine + {@code basePath("/templates/")},
+ * {@code ThymeleafView.of("hello")} resolves to {@code /templates/hello.html} on the classpath.
+ *
+ * <h5 class='section'>Immutability:</h5>
+ *
+ * <p>
+ * Each {@code attr(...)} / {@code attrs(...)} / {@code header(...)} call returns a <b>new</b>
+ * {@code ThymeleafView} carrying the additional binding; the original instance is unchanged. This
+ * keeps {@code ThymeleafView} 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 BasicThymeleafResource}
+ * 	<li class='jc'>{@link ThymeleafViewRenderer}
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/ThymeleafViewSupport">Thymeleaf View Support</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public final class ThymeleafView implements View {
+
+	private final String templateName;
+	private final Map<String, Object> attributes;
+	private final Map<String, String> responseHeaders;
+
+	/**
+	 * Creates a new {@code ThymeleafView} carrying the given template name and no attributes.
+	 *
+	 * <p>
+	 * The template name is engine-relative; the configured
+	 * {@code org.thymeleaf.TemplateEngine} resolver supplies the prefix and suffix. For the
+	 * bridge's default engine: {@code "hello"} resolves to {@code <basePath>hello.html}.
+	 *
+	 * @param templateName The template name (engine-relative, no extension). Must not be
+	 * 	{@code null} or blank.
+	 * @return A new {@code ThymeleafView} instance.
+	 * @throws IllegalArgumentException If {@code templateName} is {@code null} or blank.
+	 */
+	public static ThymeleafView of(String templateName) {
+		if (templateName == null || templateName.isBlank())
+			throw illegalArg("templateName must not be null or blank");
+		return new ThymeleafView(templateName, Map.of(), Map.of());
+	}
+
+	private ThymeleafView(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>
+	 * Each attribute is exposed to the template as a named variable (e.g.
+	 * {@code th:text="${name}"} resolves the {@code "name"} key from the populated
+	 * {@code Context}). {@code null} values are rejected at build-time because a
+	 * Thymeleaf {@code Context} silently drops {@code null}-valued bindings, masking what is
+	 * typically a caller bug; rejecting up-front surfaces the mistake at the call site.
+	 *
+	 * @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 ThymeleafView} carrying the additional attribute.
+	 * @throws IllegalArgumentException If {@code key} is {@code null} or blank, or {@code value}
+	 * 	is {@code null}.
+	 */
+	public ThymeleafView 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 ThymeleafView(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 ThymeleafView} carrying the additional attributes.
+	 * @throws IllegalArgumentException If any entry has a {@code null}/blank key or a {@code null}
+	 * 	value.
+	 */
+	public ThymeleafView 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 ThymeleafView(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 ThymeleafView} carrying the additional response header.
+	 * @throws IllegalArgumentException If {@code name} is {@code null} or blank, or {@code value}
+	 * 	is {@code null}.
+	 */
+	public ThymeleafView 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 ThymeleafView(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 "ThymeleafView[template=" + templateName + ", attrs=" + attributes.keySet()
+			+ ", headers=" + responseHeaders.keySet() + "]";
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafViewRenderer.java b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafViewRenderer.java
new file mode 100644
index 0000000000..29759e9cc0
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafViewRenderer.java
@@ -0,0 +1,141 @@
+/*
+ * 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.thymeleaf;
+
+import java.io.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.processor.*;
+import org.thymeleaf.*;
+import org.thymeleaf.context.*;
+
+/**
+ * {@link ResponseProcessor} that detects {@link ThymeleafView}-typed return values and asks the
+ * configured {@link TemplateEngine} to render them directly onto the response writer.
+ *
+ * <p>
+ * Auto-registered by {@link BasicThymeleafResource} 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 ThymeleafView} 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 ThymeleafView}, return
+ * 		{@link ResponseProcessor#NEXT NEXT} so the rest of the chain runs.
+ * 	<li>Read the active {@link BasicThymeleafResource} from the {@code RestContext} bean store to
+ * 		discover the {@link TemplateEngine} (lazy default if no engine bean is registered).
+ * 	<li>Build a Thymeleaf {@link Context} populated with the view's attribute map.
+ * 	<li>Apply every entry from {@link ThymeleafView#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 (Thymeleaf's HTML template mode emits HTML; an explicit caller header wins).
+ * 	<li>Call {@code templateEngine.process(view.getTemplateName(), context, res.getWriter())} to
+ * 		stream the rendered output.
+ * 	<li>When no Thymeleaf engine is on the classpath, the {@code TemplateEngine}-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 BasicThymeleafResource}
+ * 	<li class='jc'>{@link ThymeleafView}
+ * 	<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/ThymeleafViewSupport">Thymeleaf View Support</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class ThymeleafViewRenderer 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 Thymeleaf 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 Thymeleaf engine is available on the classpath. Add one of:\n"
+		+ "  - org.springframework.boot:spring-boot-starter-thymeleaf  (Spring Boot autoconfig)\n"
+		+ "  - org.thymeleaf:thymeleaf                                  (Juneau microservice / Jetty)\n"
+		+ "Or register a custom @Bean TemplateEngine that picks up your preferred resolvers.\n"
+		+ "See https://juneau.apache.org/docs/topics/ThymeleafViewSupport 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 ThymeleafView view))
+			return NEXT;
+
+		// Resolve the bridge resource (carries TemplateEngine + cached default). Fall back to a
+		// fresh BasicThymeleafResource when the renderer is used standalone without the mixin.
+		var bridge = req.getContext().getBeanStore()
+			.getBean(BasicThymeleafResource.class)
+			.orElseGet(BasicThymeleafResource::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);
+
+		try {
+			var engine = bridge.resolveTemplateEngine(req);
+			var ctx = newContext(req, view);
+			engine.process(view.getTemplateName(), ctx, res.getWriter());
+			return FINISHED;
+		} catch (LinkageError ex) {
+			throw new InternalServerError(ex, NO_ENGINE_DIAGNOSTIC);
+		} catch (IOException ex) {
+			throw ex;
+		} catch (Exception ex) {
+			throw new InternalServerError(ex, "Thymeleaf render failed for ''{0}''",
+				view.getTemplateName());
+		}
+	}
+
+	/**
+	 * Builds a fresh Thymeleaf {@link Context} for the current request, populated with every
+	 * entry from {@link ThymeleafView#getAttributes()}.
+	 *
+	 * <p>
+	 * The {@code Context} inherits the request's {@link java.util.Locale Locale} so Thymeleaf's
+	 * i18n machinery (`#messages`, `MessageSource`) picks it up automatically.
+	 *
+	 * @param req The current REST request.
+	 * @param view The view being rendered.
+	 * @return A new {@link Context} carrying the view's attributes.
+	 */
+	static Context newContext(RestRequest req, ThymeleafView view) {
+		var ctx = new Context(req.getLocale());
+		view.getAttributes().forEach(ctx::setVariable);
+		return ctx;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/package-info.java b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/package-info.java
new file mode 100644
index 0000000000..c0c196a17f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/package-info.java
@@ -0,0 +1,91 @@
+/*
+ * 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.
+ */
+/**
+ * Thymeleaf view-rendering bridge &mdash; opt-in {@code juneau-rest-server-view-thymeleaf} module.
+ *
+ * <p>
+ * Provides three sibling pieces that together let a Juneau REST resource render Thymeleaf
+ * templates:
+ *
+ * <ul class='javatreec'>
+ * 	<li class='jc'>{@link org.apache.juneau.rest.view.thymeleaf.BasicThymeleafResource} &mdash;
+ * 		mixin that renders raw {@code .html} templates from the importer's classpath under
+ * 		{@code /thymeleaf/*} and auto-registers
+ * 		{@link org.apache.juneau.rest.view.thymeleaf.ThymeleafViewRenderer}.
+ * 	<li class='jc'>{@link org.apache.juneau.rest.view.thymeleaf.ThymeleafView} &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 Thymeleaf template.
+ * 	<li class='jc'>{@link org.apache.juneau.rest.view.thymeleaf.ThymeleafViewRenderer} &mdash;
+ * 		{@link org.apache.juneau.rest.processor.ResponseProcessor ResponseProcessor} that detects
+ * 		{@link org.apache.juneau.rest.view.thymeleaf.ThymeleafView ThymeleafView} returns and
+ * 		dispatches them to the configured
+ * 		{@link org.thymeleaf.TemplateEngine TemplateEngine} via
+ * 		{@code engine.process(templateName, context, writer)}.
+ * </ul>
+ *
+ * <h5 class='figure'>Composition example (microservice):</h5>
+ *
+ * <p class='bjava'>
+ * 	<ja>@Rest</ja>(path=<js>"/app"</js>, mixins=BasicThymeleafResource.<jk>class</jk>)
+ * 	<jk>public class</jk> AppResource <jk>extends</jk> RestServlet {
+ *
+ * 		<ja>@Bean</ja> BasicThymeleafResource thymeleaf() {
+ * 			<jk>return</jk> BasicThymeleafResource.<jsm>create</jsm>()
+ * 				.basePath(<js>"/templates/"</js>)
+ * 				.build();
+ * 		}
+ *
+ * 		<ja>@RestGet</ja>(<js>"/hello/{name}"</js>)
+ * 		<jk>public</jk> View hello(<ja>@Path</ja> String <jv>name</jv>) {
+ * 			<jk>return</jk> ThymeleafView.<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.thymeleaf:thymeleaf} in
+ * {@code provided} scope &mdash; <b>no concrete Thymeleaf transitive dep</b> ships here. Consumers
+ * add the engine matching their deployment:
+ *
+ * <ul class='spaced-list'>
+ * 	<li><b>Spring Boot:</b> add {@code org.springframework.boot:spring-boot-starter-thymeleaf}
+ * 		&mdash; brings in {@code thymeleaf} + {@code thymeleaf-spring6} + autoconfigured
+ * 		{@code SpringTemplateEngine}. The bridge picks the engine up automatically via
+ * 		{@code BeanStore.getBean(TemplateEngine.class)}.
+ * 	<li><b>Juneau microservice / Jetty:</b> add {@code org.thymeleaf:thymeleaf} directly. With no
+ * 		user-supplied engine bean, the bridge builds a default {@link org.thymeleaf.TemplateEngine
+ * 		TemplateEngine} on first request anchored on the importer's classloader.
+ * 	<li><b>Custom:</b> register your own {@code @Bean TemplateEngine} with whatever resolvers,
+ * 		dialects, and template modes you need.
+ * </ul>
+ *
+ * <p>
+ * When no Thymeleaf engine is on the classpath, the renderer surfaces a clear diagnostic naming
+ * the missing dependency and linking to the "Choosing a TemplateEngine" matrix in
+ * {@code ThymeleafViewSupport.md}.
+ *
+ * <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/ThymeleafViewSupport">Thymeleaf 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.thymeleaf;
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index 787e69f127..b4ff80d0eb 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -38,6 +38,7 @@
 		<module>juneau-rest-server-mcp</module>
 		<module>juneau-rest-server-jwt</module>
 		<module>juneau-rest-server-view-jsp</module>
+		<module>juneau-rest-server-view-thymeleaf</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 9ed5cbcfc5..e33c607e81 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -201,6 +201,31 @@
 			<version>${jetty.version}</version>
 			<scope>test</scope>
 		</dependency>
+		<!--
+			Thymeleaf view bridge + engines for the BasicThymeleafResource / ThymeleafView test
+			matrix. Engine-agnostic POM stance (TODO-82 resolved decision #2 / mirrored from
+			TODO-78 #2): the bridge module itself has no concrete engine dep, so juneau-utest
+			supplies thymeleaf in `test` scope for the MockRest / Jetty flavors, and the Spring
+			Boot starter for the Spring-Boot flavor (autoconfigured SpringTemplateEngine).
+		-->
+		<dependency>
+			<groupId>org.apache.juneau</groupId>
+			<artifactId>juneau-rest-server-view-thymeleaf</artifactId>
+			<version>${project.version}</version>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.thymeleaf</groupId>
+			<artifactId>thymeleaf</artifactId>
+			<version>3.1.3.RELEASE</version>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-thymeleaf</artifactId>
+			<version>${spring.version}</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/thymeleaf/BasicThymeleafResource_Builder_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_Builder_Test.java
new file mode 100644
index 0000000000..51df1d78f8
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_Builder_Test.java
@@ -0,0 +1,175 @@
+/*
+ * 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.thymeleaf;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+import org.thymeleaf.templatemode.*;
+
+/**
+ * Unit tests for the {@link BasicThymeleafResource.Builder} contract + {@code stripBasePath}
+ * helper.
+ *
+ * <p>
+ * Mirrors the {@code BasicJspResource_Builder_Test} shape so sibling view modules can follow the
+ * same template.
+ *
+ * @since 9.5.0
+ */
+class BasicThymeleafResource_Builder_Test extends TestBase {
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Section A: builder surface
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Test void a01_defaultBasePathIsRoot() {
+		var r = BasicThymeleafResource.create().build();
+		assertEquals(BasicThymeleafResource.DEFAULT_BASE_PATH, r.getBasePath());
+		assertEquals("/", r.getBasePath());
+	}
+
+	@Test void a02_basePathSetterRoundTrips() {
+		var r = BasicThymeleafResource.create().basePath("/templates/").build();
+		assertEquals("/templates/", r.getBasePath());
+	}
+
+	@Test void a03_basePathNullResetsToDefault() {
+		var r = BasicThymeleafResource.create()
+			.basePath("/templates/")
+			.basePath(null)
+			.build();
+		assertEquals(BasicThymeleafResource.DEFAULT_BASE_PATH, r.getBasePath());
+	}
+
+	@Test void a04_basePathBlankResetsToDefault() {
+		var r = BasicThymeleafResource.create()
+			.basePath("/templates/")
+			.basePath("   ")
+			.build();
+		assertEquals(BasicThymeleafResource.DEFAULT_BASE_PATH, r.getBasePath());
+	}
+
+	@Test void a05_builderGetBasePathReadsBeforeBuild() {
+		var b = BasicThymeleafResource.create();
+		assertEquals(BasicThymeleafResource.DEFAULT_BASE_PATH, b.getBasePath());
+		b.basePath("/templates/");
+		assertEquals("/templates/", b.getBasePath());
+	}
+
+	@Test void a06_noArgConstructorUsesDefaultBasePath() {
+		var r = new BasicThymeleafResource();
+		assertEquals(BasicThymeleafResource.DEFAULT_BASE_PATH, r.getBasePath());
+		assertEquals(BasicThymeleafResource.DEFAULT_CACHE_TEMPLATES, r.isCacheTemplates());
+		assertEquals(BasicThymeleafResource.DEFAULT_TEMPLATE_MODE, r.getTemplateMode());
+	}
+
+	@Test void a07_cacheTemplatesDefaultsTrue() {
+		var r = BasicThymeleafResource.create().build();
+		assertTrue(r.isCacheTemplates(), "Default cacheTemplates must be true (production-safe)");
+	}
+
+	@Test void a08_cacheTemplatesSetterRoundTrips() {
+		var r = BasicThymeleafResource.create().cacheTemplates(false).build();
+		assertFalse(r.isCacheTemplates());
+	}
+
+	@Test void a09_templateModeDefaultsToHtml() {
+		var r = BasicThymeleafResource.create().build();
+		assertEquals(TemplateMode.HTML, r.getTemplateMode());
+	}
+
+	@Test void a10_templateModeSetterRoundTrips() {
+		var r = BasicThymeleafResource.create().templateMode(TemplateMode.XML).build();
+		assertEquals(TemplateMode.XML, r.getTemplateMode());
+	}
+
+	@Test void a11_templateModeNullResetsToDefault() {
+		var r = BasicThymeleafResource.create()
+			.templateMode(TemplateMode.XML)
+			.templateMode(null)
+			.build();
+		assertEquals(BasicThymeleafResource.DEFAULT_TEMPLATE_MODE, r.getTemplateMode());
+	}
+
+	@Test void a12_builderReadersReflectMutations() {
+		var b = BasicThymeleafResource.create()
+			.basePath("/views/")
+			.cacheTemplates(false)
+			.templateMode(TemplateMode.TEXT);
+		assertEquals("/views/", b.getBasePath());
+		assertFalse(b.isCacheTemplates());
+		assertEquals(TemplateMode.TEXT, b.getTemplateMode());
+	}
+
+	@Test void a13_defaultEngineIsLazyButReused() {
+		// resolveTemplateEngine builds the default engine on first call; second call must
+		// return the cached instance. (Calls go through a request-scoped lookup in the renderer;
+		// the helper here exercises only the lazy-construction branch.)
+		var r = BasicThymeleafResource.create().build();
+		var e1 = r.buildDefaultEngine();
+		var e2 = r.buildDefaultEngine();
+		// buildDefaultEngine returns a fresh instance each call; the caching is in
+		// resolveTemplateEngine itself. This test pins the contract that the constructor is
+		// idempotent (each call returns a non-null engine with the configured resolver).
+		assertNotNull(e1);
+		assertNotNull(e2);
+		assertNotSame(e1, e2,
+			"buildDefaultEngine is the constructor — the caching lives in resolveTemplateEngine");
+	}
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Section B: stripBasePath helper
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Test void b01_stripBasePathRemovesPrefix() {
+		assertEquals("hello",
+			BasicThymeleafResource.stripBasePath("/templates/", "/templates/hello"));
+	}
+
+	@Test void b02_stripBasePathHandlesMissingTrailingSlash() {
+		assertEquals("hello",
+			BasicThymeleafResource.stripBasePath("/templates", "/templates/hello"));
+	}
+
+	@Test void b03_stripBasePathHandlesRootBase() {
+		assertEquals("hello",
+			BasicThymeleafResource.stripBasePath("/", "/hello"));
+	}
+
+	@Test void b04_stripBasePathHandlesNullBase() {
+		// null base normalizes to "/" (matches the helper's contract).
+		assertEquals("hello",
+			BasicThymeleafResource.stripBasePath(null, "/hello"));
+	}
+
+	@Test void b05_stripBasePathHandlesEmptyBase() {
+		assertEquals("hello",
+			BasicThymeleafResource.stripBasePath("", "/hello"));
+	}
+
+	@Test void b06_stripBasePathHandlesMultiSegment() {
+		assertEquals("admin/dashboard",
+			BasicThymeleafResource.stripBasePath("/templates/", "/templates/admin/dashboard"));
+	}
+
+	@Test void b07_stripBasePathThrowsWhenResolvedOutsideBase() {
+		assertThrows(IllegalArgumentException.class,
+			() -> BasicThymeleafResource.stripBasePath("/templates/", "/other/hello"));
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_MockRest_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_MockRest_Test.java
new file mode 100644
index 0000000000..b4c67fd9c6
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_MockRest_Test.java
@@ -0,0 +1,144 @@
+/*
+ * 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.thymeleaf;
+
+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 BasicThymeleafResource} composition + route wiring.
+ *
+ * <p>
+ * Unlike the JSP module, Thymeleaf's core engine has <b>zero servlet-container dependencies</b>
+ * &mdash; it renders directly to a {@link java.io.Writer Writer}. That makes the
+ * <i>raw-render path</i> (the mixin's own {@code /thymeleaf/*} handler that calls
+ * {@code engine.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 ThymeleafView} renders through
+ * {@link ThymeleafViewRenderer} 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 ThymeleafView} is
+ * serialized as a Juneau-bean HTML table instead of routed through Thymeleaf. The response-
+ * processor "prepend" mechanism that fixes this is tracked separately (TODO-96) and blocks the
+ * typed-handler integration matrix for all three sibling view modules (JSP / Thymeleaf /
+ * Mustache). The typed-handler test surface lights up alongside the real-container coverage
+ * once TODO-96 lands.
+ *
+ * <h5 class='figure'>Test resource layout (classpath {@code src/test/resources}):</h5>
+ *
+ * <pre>
+ *   /templates/hello.html       ← greets "Hello, [[${name}]]!" (used by future typed-handler tests)
+ *   /templates/about.html       ← static "About Juneau"
+ *   /templates/nested/inner.html ← validates multi-segment paths
+ * </pre>
+ *
+ * @since 9.5.0
+ */
+class BasicThymeleafResource_MockRest_Test extends TestBase {
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Resource A: mixin only — default base path "/" + default engine
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Rest(mixins=BasicThymeleafResource.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_thymeleafMountInstalled_rendersTemplate() throws Exception {
+		ca.get("/thymeleaf/templates/about")
+			.run()
+			.assertStatus(200)
+			.assertContent().asString().isContains("About Juneau");
+	}
+
+	@Test void a03_thymeleafMountInstalled_rendersDotHtml() throws Exception {
+		// Trailing .html is stripped by the handler; both work.
+		ca.get("/thymeleaf/templates/about.html")
+			.run()
+			.assertStatus(200)
+			.assertContent().asString().isContains("About Juneau");
+	}
+
+	@Test void a05_nonMixinPathFallsThrough() throws Exception {
+		ca.get("/does-not-exist")
+			.run()
+			.assertStatus(404);
+	}
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Resource B: configured base path "/templates/" — raw render uses engine-relative names
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Rest(mixins=BasicThymeleafResource.class)
+	public static class B extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@Bean public BasicThymeleafResource thymeleaf() {
+			return BasicThymeleafResource.create()
+				.basePath("/templates/")
+				.cacheTemplates(false)
+				.build();
+		}
+	}
+
+	private static final MockRestClient cb = MockRestClient.buildLax(B.class);
+
+	@Test void b01_renderResolvesUnderConfiguredBasePath() throws Exception {
+		// basePath = /templates/, so /thymeleaf/about → engine.process("about", ...) →
+		// resolver looks up /templates/about.html on the classpath.
+		cb.get("/thymeleaf/about")
+			.run()
+			.assertStatus(200)
+			.assertContent().asString().isContains("About Juneau");
+	}
+
+	@Test void b02_renderHandlesMultiSegmentPath() throws Exception {
+		cb.get("/thymeleaf/nested/inner.html")
+			.run()
+			.assertStatus(200)
+			.assertContent().asString().isContains("inner template");
+	}
+
+	@Test void b03_contentTypeDefaultsToHtml() throws Exception {
+		cb.get("/thymeleaf/about")
+			.run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("text/html");
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_PathTraversal_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_PathTraversal_Test.java
new file mode 100644
index 0000000000..84dbe02d96
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_PathTraversal_Test.java
@@ -0,0 +1,105 @@
+/*
+ * 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.thymeleaf;
+
+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 BasicThymeleafResource#render render(...)}.
+ *
+ * <p>
+ * Mirrors {@code BasicJspResource_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
+ * Thymeleaf analog of TODO-97 that lights up Jetty/Spring Boot end-to-end alongside the JSP
+ * module.)
+ *
+ * @since 9.5.0
+ */
+class BasicThymeleafResource_PathTraversal_Test extends TestBase {
+
+	@Rest(mixins=BasicThymeleafResource.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 BasicThymeleafResource thymeleaf() {
+			return BasicThymeleafResource.create().basePath("/templates/").build();
+		}
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(A.class);
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Baseline — well-formed requests reach the engine (real template renders since Thymeleaf
+	// works under MockRest, so we expect 200, not 403).
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void t01_normalAccess_passesBoundaryCheck() throws Exception {
+		var status = c.get("/thymeleaf/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("/thymeleaf/../secret").run().getStatusCode();
+		assertEquals(403, status, "GET /thymeleaf/../secret must be rejected (escapes /templates/)");
+	}
+
+	@Test void t03_nestedTraversal_returns403() throws Exception {
+		var status = c.get("/thymeleaf/a/b/../../../secret").run().getStatusCode();
+		assertEquals(403, status, "GET /thymeleaf/a/b/../../../secret must be rejected");
+	}
+
+	@Test void t04_traversalToSibling_returns403() throws Exception {
+		// /thymeleaf/../views2/foo → after join: /templates/../views2/foo →
+		// normalized: /views2/foo → does NOT start with /templates/ → 403.
+		var status = c.get("/thymeleaf/../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("/thymeleaf/%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/thymeleaf/BasicThymeleafResource_SvlPathOverride_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_SvlPathOverride_Test.java
new file mode 100644
index 0000000000..2d3c0da67a
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/BasicThymeleafResource_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.thymeleaf;
+
+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.thymeleaf.path:thymeleaf}} SVL override on
+ * {@link BasicThymeleafResource}.
+ *
+ * <p>
+ * Thymeleaf 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 /thymeleaf/*} 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 BasicThymeleafResource_SvlPathOverride_Test extends TestBase {
+
+	@Rest(mixins=BasicThymeleafResource.class)
+	public static class A01_OverridePath extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	@Test void a01_svlOverrideChangesPath() throws Exception {
+		var key = "juneau.thymeleaf.path";
+		var prev = System.getProperty(key);
+		System.setProperty(key, "views");
+		try {
+			var c = MockRestClient.buildLax(A01_OverridePath.class);
+
+			c.get("/thymeleaf/templates/about").run().assertStatus(404);
+
+			c.get("/views/templates/about")
+				.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/thymeleaf/ThymeleafViewRenderer_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafViewRenderer_Test.java
new file mode 100644
index 0000000000..fb0474b682
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafViewRenderer_Test.java
@@ -0,0 +1,105 @@
+/*
+ * 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.thymeleaf;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link ThymeleafViewRenderer}.
+ *
+ * <p>
+ * Covers the diagnostic-message constant and the {@code Context}-building helper. The full
+ * {@link ThymeleafViewRenderer#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 Thymeleaf analog of TODO-97 for JSP).
+ *
+ * @since 9.5.0
+ */
+class ThymeleafViewRenderer_Test extends TestBase {
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Section A: NO_ENGINE_DIAGNOSTIC text
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Test void a01_diagnosticNamesSpringBootStarter() {
+		assertTrue(ThymeleafViewRenderer.NO_ENGINE_DIAGNOSTIC.contains("spring-boot-starter-thymeleaf"),
+			"Diagnostic must name the Spring Boot starter dependency");
+	}
+
+	@Test void a02_diagnosticNamesThymeleafCore() {
+		assertTrue(ThymeleafViewRenderer.NO_ENGINE_DIAGNOSTIC.contains("org.thymeleaf:thymeleaf"),
+			"Diagnostic must name the Thymeleaf core dependency");
+	}
+
+	@Test void a03_diagnosticLinksToDocs() {
+		assertTrue(ThymeleafViewRenderer.NO_ENGINE_DIAGNOSTIC.contains("ThymeleafViewSupport"),
+			"Diagnostic must link to the Thymeleaf topic page");
+	}
+
+	@Test void a04_defaultContentTypeIsHtmlUtf8() {
+		assertEquals("text/html;charset=UTF-8", ThymeleafViewRenderer.DEFAULT_CONTENT_TYPE);
+	}
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Section B: newContext(...) attribute population
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Test void b01_newContextCopiesAllAttributes() {
+		var view = ThymeleafView.of("hello")
+			.attr("name", "Bob")
+			.attr("age", 42)
+			.attr("active", Boolean.TRUE);
+
+		var req = mock(RestRequest.class);
+		when(req.getLocale()).thenReturn(Locale.US);
+
+		var ctx = ThymeleafViewRenderer.newContext(req, view);
+
+		assertEquals(Locale.US, ctx.getLocale());
+		assertEquals("Bob", ctx.getVariable("name"));
+		assertEquals(42, ctx.getVariable("age"));
+		assertEquals(Boolean.TRUE, ctx.getVariable("active"));
+	}
+
+	@Test void b02_newContextUsesRequestLocale() {
+		var view = ThymeleafView.of("hello");
+
+		var req = mock(RestRequest.class);
+		when(req.getLocale()).thenReturn(Locale.GERMAN);
+
+		var ctx = ThymeleafViewRenderer.newContext(req, view);
+		assertEquals(Locale.GERMAN, ctx.getLocale());
+	}
+
+	@Test void b03_newContextEmptyViewProducesEmptyContext() {
+		var view = ThymeleafView.of("hello");
+
+		var req = mock(RestRequest.class);
+		when(req.getLocale()).thenReturn(Locale.US);
+
+		var ctx = ThymeleafViewRenderer.newContext(req, view);
+		assertTrue(ctx.getVariableNames().isEmpty(),
+			"newContext on an attribute-less view must produce a variable-less Context");
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafView_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafView_Test.java
new file mode 100644
index 0000000000..7aae223e4e
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafView_Test.java
@@ -0,0 +1,226 @@
+/*
+ * 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.thymeleaf;
+
+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 ThymeleafView}.
+ *
+ * <p>
+ * Covers the immutable-value-class contract: {@link ThymeleafView#of(String) of(...)} validation,
+ * fluent {@link ThymeleafView#attr(String, Object) attr(...)} /
+ * {@link ThymeleafView#attrs(Map) attrs(...)} /
+ * {@link ThymeleafView#header(String, String) header(...)} chaining, and the {@link View}-interface
+ * surface ({@code getTemplateName} / {@code getAttributes} / {@code getResponseHeaders}).
+ *
+ * @since 9.5.0
+ */
+class ThymeleafView_Test extends TestBase {
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Section A: factory + invariants
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Test void a01_ofProducesViewWithTemplateAndEmptyMaps() {
+		var v = ThymeleafView.of("hello");
+		assertEquals("hello", 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, () -> ThymeleafView.of(null));
+	}
+
+	@Test void a03_ofRejectsBlankTemplate() {
+		assertThrows(IllegalArgumentException.class, () -> ThymeleafView.of(""));
+		assertThrows(IllegalArgumentException.class, () -> ThymeleafView.of("   "));
+	}
+
+	@Test void a04_isViewInstance() {
+		// Verifies the type relationship that ResponseProcessor pattern-matches on.
+		View v = ThymeleafView.of("x");
+		assertNotNull(v);
+	}
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Section B: attr(...)
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Test void b01_attrAddsBindingOnNewInstance() {
+		var v0 = ThymeleafView.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 = ThymeleafView.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 = ThymeleafView.of("hello")
+			.attr("name", "Bob")
+			.attr("name", "Alice");
+
+		assertEquals("Alice", v.getAttributes().get("name"));
+		assertEquals(1, v.getAttributes().size());
+	}
+
+	@Test void b04_attrRejectsNullValue() {
+		// Thymeleaf Context silently drops null bindings; reject at build-time so the mistake
+		// surfaces at the call site rather than as a missing template variable downstream.
+		assertThrows(IllegalArgumentException.class,
+			() -> ThymeleafView.of("hello").attr("missing", null));
+	}
+
+	@Test void b05_attrRejectsNullKey() {
+		assertThrows(IllegalArgumentException.class,
+			() -> ThymeleafView.of("hello").attr(null, "v"));
+	}
+
+	@Test void b06_attrRejectsBlankKey() {
+		assertThrows(IllegalArgumentException.class,
+			() -> ThymeleafView.of("hello").attr("", "v"));
+		assertThrows(IllegalArgumentException.class,
+			() -> ThymeleafView.of("hello").attr("   ", "v"));
+	}
+
+	@Test void b07_attributesMapIsImmutable() {
+		var v = ThymeleafView.of("hello").attr("name", "Bob");
+		assertThrows(UnsupportedOperationException.class,
+			() -> v.getAttributes().put("name", "Alice"));
+	}
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Section C: attrs(...)
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Test void c01_attrsMergesMap() {
+		var v = ThymeleafView.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 = ThymeleafView.of("hello");
+		var v1 = v0.attrs(null);
+		assertSame(v0, v1, "attrs(null) is a no-op");
+	}
+
+	@Test void c03_attrsEmptyReturnsSameInstance() {
+		var v0 = ThymeleafView.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,
+			() -> ThymeleafView.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,
+			() -> ThymeleafView.of("hello").attrs(m));
+	}
+
+	@Test void c06_attrsOverridesExistingBindings() {
+		var v = ThymeleafView.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 = ThymeleafView.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 = ThymeleafView.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,
+			() -> ThymeleafView.of("hello").header("", "v"));
+		assertThrows(IllegalArgumentException.class,
+			() -> ThymeleafView.of("hello").header(null, "v"));
+	}
+
+	@Test void d04_headerRejectsNullValue() {
+		assertThrows(IllegalArgumentException.class,
+			() -> ThymeleafView.of("hello").header("X", null));
+	}
+
+	@Test void d05_headerReturnsNewInstance() {
+		var v0 = ThymeleafView.of("hello");
+		var v1 = v0.header("X", "1");
+		assertNotSame(v0, v1);
+		assertTrue(v0.getResponseHeaders().isEmpty());
+	}
+
+	@Test void d06_responseHeadersMapIsImmutable() {
+		var v = ThymeleafView.of("hello").header("X", "1");
+		assertThrows(UnsupportedOperationException.class,
+			() -> v.getResponseHeaders().put("Y", "2"));
+	}
+
+	/* ---------------------------------------------------------------------------------------- *
+	 * Section E: toString + misc
+	 * ---------------------------------------------------------------------------------------- */
+
+	@Test void e01_toStringIncludesTemplateName() {
+		var v = ThymeleafView.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/templates/about.html b/juneau-utest/src/test/resources/templates/about.html
new file mode 100644
index 0000000000..5a38d84e07
--- /dev/null
+++ b/juneau-utest/src/test/resources/templates/about.html
@@ -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/templates/hello.html b/juneau-utest/src/test/resources/templates/hello.html
new file mode 100644
index 0000000000..ce732d9cb4
--- /dev/null
+++ b/juneau-utest/src/test/resources/templates/hello.html
@@ -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 xmlns:th="http://www.thymeleaf.org">
+<head><title>Hello</title></head>
+<body>
+<p>Hello, <span th:text="${name}">name</span>!</p>
+</body>
+</html>
diff --git a/juneau-utest/src/test/resources/templates/nested/inner.html b/juneau-utest/src/test/resources/templates/nested/inner.html
new file mode 100644
index 0000000000..96b0609c06
--- /dev/null
+++ b/juneau-utest/src/test/resources/templates/nested/inner.html
@@ -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>