diff --git a/views-thymeleaf/src/main/java/io/micronaut/views/thymeleaf/ThymeleafViewsRenderer.java b/views-thymeleaf/src/main/java/io/micronaut/views/thymeleaf/ThymeleafViewsRenderer.java index 9fc2884ec..75eda9e32 100644 --- a/views-thymeleaf/src/main/java/io/micronaut/views/thymeleaf/ThymeleafViewsRenderer.java +++ b/views-thymeleaf/src/main/java/io/micronaut/views/thymeleaf/ThymeleafViewsRenderer.java @@ -30,13 +30,18 @@ import io.micronaut.views.exceptions.ViewRenderingException; import jakarta.inject.Singleton; import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.ExpressionContext; import org.thymeleaf.context.IContext; import org.thymeleaf.exceptions.TemplateEngineException; +import org.thymeleaf.exceptions.TemplateProcessingException; +import org.thymeleaf.standard.expression.FragmentExpression; +import org.thymeleaf.standard.expression.StandardExpressions; import org.thymeleaf.templateresolver.AbstractConfigurableTemplateResolver; import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; import java.io.Writer; import java.util.Locale; +import java.util.Set; /** * Renders templates Thymeleaf Java template engine. @@ -81,18 +86,39 @@ public Writable render(@NonNull String viewName, ArgumentUtils.requireNonNull("viewName", viewName); return (writer) -> { IContext context = new WebContext(request, request != null ? httpLocaleResolver.resolveOrDefault(request) : Locale.getDefault(), - ViewUtils.modelOf(data)); - render(viewName, context, writer); + ViewUtils.modelOf(data)); + + var templateAndFragment = resolveTemplate(viewName); + + render(templateAndFragment.templateName, templateAndFragment.fragmentSelectors, context, writer); }; } + /** + * Passes the arguments as is to {@link TemplateEngine#process(String, IContext, Writer)}. + * + * @param viewName The view name + * @param fragmentSelectors Fragment selectors + * @param context The context + * @param writer The writer + */ + public void render(String viewName, Set fragmentSelectors, IContext context, Writer writer) { + try { + engine.process(viewName, fragmentSelectors, context, writer); + } catch (TemplateEngineException e) { + throw new ViewRenderingException("Error rendering Thymeleaf view [" + viewName + "]: " + e.getMessage(), e); + } + } + /** * Passes the arguments as is to {@link TemplateEngine#process(String, IContext, Writer)}. * * @param viewName The view name * @param context The context * @param writer The writer + * @deprecated Use {@link #render(String, Set, IContext, Writer)} instead. */ + @Deprecated(forRemoval = true, since = "5.2.0") public void render(String viewName, IContext context, Writer writer) { try { engine.process(viewName, context, writer); @@ -103,7 +129,8 @@ public void render(String viewName, IContext context, Writer writer) { @Override public boolean exists(@NonNull String viewName) { - String location = viewLocation(viewName); + var templateAndFragment = resolveTemplate(viewName); + String location = viewLocation(templateAndFragment.templateName); return resourceLoader.getResourceAsStream(location).isPresent(); } @@ -131,8 +158,30 @@ private ClassLoaderTemplateResolver initializeTemplateResolver(ViewsConfiguratio private String viewLocation(final String name) { return templateResolver.getPrefix() + - ViewUtils.normalizeFile(name, templateResolver.getSuffix()) + - templateResolver.getSuffix(); + ViewUtils.normalizeFile(name, templateResolver.getSuffix()) + + templateResolver.getSuffix(); } + private record TemplateAndFragment(String templateName, Set fragmentSelectors) { + } + + private TemplateAndFragment resolveTemplate(String viewName) { + if (!viewName.contains("::")) { + return new TemplateAndFragment(viewName, null); + } + + var expressionContext = new ExpressionContext(engine.getConfiguration()); + var parser = StandardExpressions.getExpressionParser(engine.getConfiguration()); + FragmentExpression fragmentExpression; + try { + fragmentExpression = (FragmentExpression) parser.parseExpression(expressionContext, "~{" + viewName + "}"); + } catch (TemplateProcessingException e) { + throw new IllegalArgumentException("Invalid template name specification: '" + viewName + "'"); + } + var fragment = FragmentExpression.createExecutedFragmentExpression(expressionContext, fragmentExpression); + var templateName = FragmentExpression.resolveTemplateName(fragment); + var fragmentSelectors = FragmentExpression.resolveFragments(fragment); + + return new TemplateAndFragment(templateName, fragmentSelectors); + } } diff --git a/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/ThymeleafViewRenderFragmentSpec.groovy b/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/ThymeleafViewRenderFragmentSpec.groovy new file mode 100644 index 000000000..1bb72051d --- /dev/null +++ b/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/ThymeleafViewRenderFragmentSpec.groovy @@ -0,0 +1,40 @@ +package io.micronaut.views.thymeleaf + +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +import static io.micronaut.views.thymeleaf.WriteableUtils.* + +@MicronautTest(startApplication = false) +class ThymeleafViewRenderFragmentSpec extends Specification { + + @Inject + ThymeleafViewsRenderer viewRenderer + + void "can render fragment"() { + expect: + "
FRAGMENT
" == writableToString(viewRenderer.render("fragment :: thefragment", ["some": "data"], null)) + + and: + "
FRAGMENT 2
" == writableToString(viewRenderer.render("fragment :: thefragment2", ["some": "data"], null)) + } + + void "can render main body"() { + when: + String result = writableToString(viewRenderer.render("fragment", ["some": "data"], null)) + + then: + result.contains("MAIN") && result.contains("FRAGMENT") + } + + void "exists is successful when using fragments"() { + expect: + viewRenderer.exists("fragment :: thefragment") + } + + void "exists is successful when using regular view name"() { + expect: + viewRenderer.exists("fragment") + } +} diff --git a/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/ThymeleafViewRenderNullableRequestSpec.groovy b/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/ThymeleafViewRenderNullableRequestSpec.groovy index 6731b6ca2..2cb416eb3 100644 --- a/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/ThymeleafViewRenderNullableRequestSpec.groovy +++ b/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/ThymeleafViewRenderNullableRequestSpec.groovy @@ -13,11 +13,7 @@ class ThymeleafViewRenderNullableRequestSpec extends Specification { void "views can be render with no request"() { when: - Writable writeable = viewRenderer.render("tim", ["username": "Tim"], null) - String result = new StringWriter().with { - writeable.writeTo(it) - it.toString() - } + String result = WriteableUtils.writableToString(viewRenderer.render("tim", ["username": "Tim"], null)) then: result.contains("username: Tim") diff --git a/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/WriteableUtils.groovy b/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/WriteableUtils.groovy new file mode 100644 index 000000000..8b1f3c5b6 --- /dev/null +++ b/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/WriteableUtils.groovy @@ -0,0 +1,16 @@ +package io.micronaut.views.thymeleaf + +import io.micronaut.core.io.Writable + +final class WriteableUtils { + private WriteableUtils() { + + } + + static String writableToString(Writable writable) { + return new StringWriter().with { + writable.writeTo(it) + it.toString() + } + } +} diff --git a/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/web/LinkTestController.groovy b/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/web/LinkTestController.groovy index 4e2eca995..fb0f8659b 100644 --- a/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/web/LinkTestController.groovy +++ b/views-thymeleaf/src/test/groovy/io/micronaut/views/thymeleaf/web/LinkTestController.groovy @@ -12,7 +12,7 @@ import io.micronaut.views.View class LinkTestController { @Get @View("contextRelativeUrl") - public HttpResponse contextRelativeUrl() { + HttpResponse contextRelativeUrl() { return HttpResponse.ok() } diff --git a/views-thymeleaf/src/test/resources/views/fragment.html b/views-thymeleaf/src/test/resources/views/fragment.html new file mode 100644 index 000000000..2d35e861b --- /dev/null +++ b/views-thymeleaf/src/test/resources/views/fragment.html @@ -0,0 +1,11 @@ + + + + Home + + +
MAIN
+
FRAGMENT
+
FRAGMENT 2
+ +