diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsRepoSettingsPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsRepoSettingsPlugin.groovy
index e14287f6442..e4675f3cd91 100644
--- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsRepoSettingsPlugin.groovy
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsRepoSettingsPlugin.groovy
@@ -47,6 +47,7 @@ class GrailsRepoSettingsPlugin implements Plugin {
url = 'https://central.sonatype.com/repository/maven-snapshots'
content {
it.includeVersionByRegex('cloud[.]wondrify.*', '.*', '.*-SNAPSHOT')
+ it.includeVersionByRegex('org[.]sitemesh.*', '.*', '.*-SNAPSHOT')
}
mavenContent {
it.snapshotsOnly()
@@ -91,6 +92,7 @@ class GrailsRepoSettingsPlugin implements Plugin {
url = 'https://central.sonatype.com/repository/maven-snapshots'
content {
it.includeVersionByRegex('cloud[.]wondrify.*', '.*', '.*-SNAPSHOT')
+ it.includeVersionByRegex('org[.]sitemesh.*', '.*', '.*-SNAPSHOT')
}
mavenContent {
it.snapshotsOnly()
diff --git a/dependencies.gradle b/dependencies.gradle
index e1dc53bbb52..a897c4ca15b 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -89,7 +89,8 @@ ext {
'selenium.version' : '4.38.0',
'spock.version' : '2.3-groovy-4.0',
'sitemesh.version' : '2.6.0',
- 'starter-sitemesh.version' : '3.2.2',
+ 'starter-sitemesh.version' : '3.2.3-SNAPSHOT',
+ 'spring-webmvc-sitemesh.version': '3.2.3-SNAPSHOT',
]
// Note: the name of the dependency must be the prefix of the property name so properties in the pom are resolved correctly
@@ -182,6 +183,7 @@ ext {
'rxjava3' : "io.reactivex.rxjava3:rxjava:${bomDependencyVersions['rxjava3.version']}",
'sitemesh' : "opensymphony:sitemesh:${bomDependencyVersions['sitemesh.version']}",
'starter-sitemesh' : "org.sitemesh:spring-boot-starter-sitemesh:${bomDependencyVersions['starter-sitemesh.version']}",
+ 'spring-webmvc-sitemesh' : "org.sitemesh:spring-webmvc-sitemesh:${bomDependencyVersions['spring-webmvc-sitemesh.version']}",
]
// Because pom exclusions aren't properly supported by gradle, we can't inherit the grails-gradle-bom
diff --git a/grails-datamapping-support/src/test/groovy/org/grails/datastore/mapping/core/grailsversion/GrailsVersionSpec.groovy b/grails-datamapping-support/src/test/groovy/org/grails/datastore/mapping/core/grailsversion/GrailsVersionSpec.groovy
index 84eb2716ceb..05deff071f7 100644
--- a/grails-datamapping-support/src/test/groovy/org/grails/datastore/mapping/core/grailsversion/GrailsVersionSpec.groovy
+++ b/grails-datamapping-support/src/test/groovy/org/grails/datastore/mapping/core/grailsversion/GrailsVersionSpec.groovy
@@ -137,4 +137,4 @@ class GrailsVersionSpec extends Specification {
thrown(IllegalArgumentException)
}
-}
\ No newline at end of file
+}
diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/layouts.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/layouts.adoc
index 1322be8e065..61d70b81ffb 100644
--- a/grails-doc/src/en/guide/theWebLayer/gsp/layouts.adoc
+++ b/grails-doc/src/en/guide/theWebLayer/gsp/layouts.adoc
@@ -20,7 +20,9 @@ under the License.
==== Creating Layouts
-This product includes software developed by the OpenSymphony Group (https://github.com/sitemesh). The Sitemesh framework is used to provide a decorator engine, to support view layouts. Layouts are located in the `grails-app/views/layouts` directory. A typical layout can be seen below:
+Grails uses the https://github.com/sitemesh/sitemesh3[SiteMesh 3] framework as its decorator engine to support view layouts. In Grails 7.2 and later, decoration is performed by a Spring MVC `ViewResolver` rather than a Servlet filter, which improves performance and enables correct support for async controller return types.
+
+Layouts are located in the `grails-app/views/layouts` directory. A typical layout can be seen below:
[source,xml]
----
@@ -128,17 +130,22 @@ Alternatively, you can create a layout called `grails-app/views/layouts/book/lis
If you have both the above-mentioned layouts in place the layout specific to the action will take precedence when the list action is executed.
If a layout is not located using any of those conventions, the convention of last resort is to look for the application default layout which
-is `grails-app/views/layouts/application.gsp`. The name of the application default layout may be changed by defining the property `grails.views.layout.default`
+is `grails-app/views/layouts/application.gsp`. The name of the application default layout may be changed by defining the property `grails.sitemesh.default.layout`
in the application configuration as follows:
[source,yaml]
.grails-app/conf/application.yml
----
-grails.views.layout.default: myLayoutName
+grails:
+ sitemesh:
+ default:
+ layout: myLayoutName
----
With that property in place, the application default layout will be `grails-app/views/layouts/myLayoutName.gsp`.
+NOTE: In Grails 7.2+ the canonical property for the default layout is `grails.sitemesh.default.layout`. The older `grails.views.layout.default` key is no longer consulted by the SiteMesh 3 integration.
+
==== Inline Layouts
@@ -186,3 +193,15 @@ def content = include(controller:"book", action:"list")
----
The resulting content will be provided via the return value of the link:{gspTagsRef}include.html[include] tag.
+
+
+==== SiteMesh 2 Attribution (grails-layout)
+
+The legacy `grails-layout` plugin remains available for applications that have not yet migrated to the SiteMesh 3 integration. When `grails-layout` is on the classpath, decoration is provided by SiteMesh 2 and the following attribution applies:
+
+[NOTE]
+====
+This product includes software developed by the OpenSymphony Group (https://github.com/sitemesh). The SiteMesh 2 framework is licensed under the OpenSymphony Software License, Version 1.1.
+====
+
+This acknowledgment is required by the OpenSymphony Software License when redistributing SiteMesh 2 and applies only to applications that include the `grails-layout` plugin. Applications using only the SiteMesh 3 integration are not subject to this requirement.
diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml
index e2aa4687128..a91ac8fdbf9 100644
--- a/grails-doc/src/en/guide/toc.yml
+++ b/grails-doc/src/en/guide/toc.yml
@@ -36,6 +36,7 @@ gettingStarted:
developmentReloading: Development Reloading
upgrading:
title: Upgrading from the previous versions
+ upgrading72x: Upgrading from Grails 7.1 to Grails 7.2
upgrading71x: Upgrading from Grails 7.0 to Grails 7.1
upgrading70x: Upgrading from Grails 6 to Grails 7.0
upgrading60x: Upgrading from Grails 5 to Grails 6
diff --git a/grails-doc/src/en/guide/upgrading/upgrading72x.adoc b/grails-doc/src/en/guide/upgrading/upgrading72x.adoc
new file mode 100644
index 00000000000..50bdeb7eace
--- /dev/null
+++ b/grails-doc/src/en/guide/upgrading/upgrading72x.adoc
@@ -0,0 +1,102 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+https://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.
+////
+
+=== Upgrade Instructions for Grails 7.1 to Grails 7.2
+
+This guide outlines the changes introduced in Grails 7.2 and the steps required to upgrade your application.
+
+==== 1. SiteMesh 3 Filter-less Integration
+
+Grails 7.2 upgrades the SiteMesh 3 integration to a *filter-less* architecture. Decoration is now performed by a Spring MVC `ViewResolver` rather than a Servlet filter. This change improves performance, eliminates ordering issues with other filters, and correctly supports async controller return types (`Callable`, `@Async`).
+
+===== What Changed
+
+* The `SiteMeshFilter` servlet filter is no longer active. A `NoopSitemeshFilter` placeholder bean is registered under the `sitemesh` name to prevent SiteMesh's auto-configuration from activating the upstream filter-based integration.
+* Layout resolution and decoration are now handled by `Sitemesh3LayoutView` (a Spring `View`) and `CaptureAwareContentProcessor`.
+* GSP pages populate a `Sitemesh3CapturedPage` at render time via the `grailsLayout` capture tag library. SiteMesh no longer parses the raw HTML response to extract head/body/title.
+
+===== Required Actions
+
+For most applications **no changes are required**. The filter-less integration is a drop-in replacement: layouts, ``, ``, ``, ``, ``, and layout-by-convention all behave identically to the previous release.
+
+[IMPORTANT]
+====
+The guidance below applies *only* when the SiteMesh 3 integration is in use. The legacy `grails-layout` plugin (SiteMesh 2) remains supported in Grails 7.2 and continues to honor its existing configuration. If your application still depends on `grails-layout`, leave the SiteMesh 2 properties in place — they remain meaningful for that codepath.
+====
+
+When using the SiteMesh 3 integration, the following SiteMesh 2-era configuration properties are no longer consulted and can be removed from your `application.yml` if present:
+
+[source,yaml]
+----
+# Remove these — no longer used by the SiteMesh 3 integration in Grails 7.2.
+# (Retain them if your application still uses the grails-layout / SiteMesh 2 plugin.)
+grails.gsp.view.layoutViewResolver: false
+sitemesh.filter.order: ...
+sitemesh.decorator.tagRuleBundles: ...
+----
+
+===== Configuring the Default Layout
+
+The default layout is still configured via:
+
+[source,yaml]
+.grails-app/conf/application.yml
+----
+grails:
+ sitemesh:
+ default:
+ layout: application
+----
+
+===== Layout Cache Tuning (Optional)
+
+In non-development environments, layout paths are cached by controller/action pair. The cache TTL can be tuned independently of the GSP reload interval using a dedicated key:
+
+[source,yaml]
+.grails-app/conf/application.yml
+----
+grails:
+ sitemesh:
+ layout:
+ cache:
+ interval: 5000 # milliseconds, default 5000
+----
+
+This replaces any reliance on `grails.gsp.reload.interval` for layout cache expiration in previous snapshots.
+
+===== Async Controller Support
+
+The filter-less architecture correctly handles Grails controllers that return `Callable` or use `@Async`. Previously, async dispatch could cause decoration to run on a different thread from the one that populated the SiteMesh request scope, leading to empty layouts or stale content. This is resolved in 7.2.
+
+===== Content Blocks
+
+Content blocks (the `` pattern) continue to work as before. The GSP preprocessor maps them to `` at compile time, and the captured buffers are passed directly to the layout without an additional HTML parse.
+
+[source,xml]
+----
+<%-- Page --%>
+
+ ... sidebar content ...
+
+
+<%-- Layout --%>
+
+----
diff --git a/grails-gsp/grails-sitemesh3/build.gradle b/grails-gsp/grails-sitemesh3/build.gradle
index 84fc041967a..5b600e9f5d0 100644
--- a/grails-gsp/grails-sitemesh3/build.gradle
+++ b/grails-gsp/grails-sitemesh3/build.gradle
@@ -38,48 +38,28 @@ ext {
dependencies {
+ astImplementation platform(project(':grails-bom'))
+
implementation platform(project(':grails-bom'))
api "org.sitemesh:spring-boot-starter-sitemesh"
+ api "org.sitemesh:spring-webmvc-sitemesh"
api project(':grails-web-gsp-taglib')
+ api project(':grails-web-gsp')
+ api project(':grails-web-common')
+ api project(':grails-gsp')
+ api project(':grails-core')
+
+ implementation 'org.apache.groovy:groovy'
-// api project(':grails-web-gsp'), { // GrailsConventionGroovyPageLocator
-// // API dependencies in grails-web-gsp
-// //exclude group: 'org.apache.grails.views', module: 'grails-gsp-core' // DefaultGroovyPageLocator
-// exclude group: 'org.apache.grails.web', module: 'grails-web-common'
-// exclude group: 'org.apache.grails.web', module: 'grails-web-taglib'
-// }
-// api project(':grails-web-gsp-taglib'), { // GrailsConventionGroovyPageLocator
-// // API dependencies in grails-web-gsp-taglib
-// exclude group: 'org.apache.grails.web', module: 'grails-taglib'
-// //exclude group: 'org.apache.grails.web', module: 'grails-web-gsp' // DefaultGroovyPageLocator
-// }
-// api "org.sitemesh:sitemesh:$sitemeshVersion" // SiteMeshFilter
-// api 'org.springframework:spring-webmvc' // AbstractHandlerAdapter, ParameterizableViewController, AbstractHandlerMapping
-// api 'org.springframework.boot:spring-boot' // FilterRegistrationBean
-//
-// implementation project(':grails-web-taglib'), { // TagLib, TagLibrary
-// // API dependencies in grails-web-taglib
-// exclude group: 'org.apache.grails.web', module: 'grails-taglib'
-// exclude group: 'org.apache.grails.web', module: 'grails-web-common'
-// }
-// implementation project(':grails-web-common'), { // WebUtils
-// // API dependencies in grails-web-common
-// exclude group: 'org.apache.groovy', module: 'groovy-templates'
-// exclude group: 'org.apache.grails', module: 'grails-core'
-// exclude group: 'org.apache.grails', module: 'grails-databinding'
-// exclude group: 'org.apache.grails', module: 'grails-encoder'
-// exclude group: 'org.springframework', module: 'spring-webmvc'
-// exclude group: 'org.springframework', module: 'spring-context-support'
-// }
-// implementation 'org.springframework:spring-beans' // Autowired, Qualifier
-// implementation 'org.springframework:spring-web' // HttpMethod, ResponseStatusException, HttpStatus
-//
-// runtimeOnly "org.apache.grails:grails-codecs:$grailsVersion" // Registers CodecLookup bean
-// runtimeOnly "org.sitemesh:spring-boot-starter-sitemesh:$sitemeshVersion"
-//
-// compileOnly 'jakarta.servlet:jakarta.servlet-api' // Provided by servlet container
-// compileOnly 'org.apache.groovy:groovy' // Provided by Grails Application
+ compileOnly 'jakarta.servlet:jakarta.servlet-api'
+ testImplementation 'jakarta.servlet:jakarta.servlet-api'
+ testImplementation 'org.spockframework:spock-core'
+ testImplementation 'org.springframework:spring-test'
+ testImplementation project(':grails-testing-support-web')
+ testImplementation 'org.objenesis:objenesis'
+ testRuntimeOnly 'net.bytebuddy:byte-buddy'
+ testRuntimeOnly 'org.slf4j:slf4j-nop'
}
apply {
diff --git a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/GrailsLayoutHandlerMapping.java b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/GrailsLayoutHandlerMapping.java
deleted file mode 100644
index 20932cf9072..00000000000
--- a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/GrailsLayoutHandlerMapping.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * 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
- *
- * https://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.grails.plugins.sitemesh3;
-
-import java.util.HashMap;
-import java.util.Map;
-
-import jakarta.servlet.http.HttpServletRequest;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.HttpMethod;
-import org.springframework.web.server.ResponseStatusException;
-import org.springframework.web.servlet.handler.AbstractHandlerMapping;
-import org.springframework.web.servlet.mvc.ParameterizableViewController;
-
-import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator;
-
-import static org.springframework.http.HttpStatus.NOT_FOUND;
-
-public class GrailsLayoutHandlerMapping extends AbstractHandlerMapping {
- @Autowired
- GrailsConventionGroovyPageLocator groovyPageLocator;
-
- Map layoutCache = new HashMap<>();
-
- public GrailsLayoutHandlerMapping() {
- setOrder(-6);
- }
-
- @Override
- protected Object getHandlerInternal(HttpServletRequest request) {
- if (request.getAttribute("jakarta.servlet.forward.request_uri") == null) {
- return null; // only handle forwarded requests.
- }
- String servletPath = request.getServletPath();
- if (servletPath.startsWith("/layouts")) {
- ParameterizableViewController pvc = layoutCache.get(servletPath);
- if (pvc == null) {
- if (groovyPageLocator.findViewByPath(servletPath) == null) {
- throw new ResponseStatusException(NOT_FOUND, "Unable to find resource " + servletPath);
- }
- pvc = new ParameterizableViewController();
- pvc.setSupportedMethods(HttpMethod.GET.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name());
- pvc.setViewName(servletPath);
- layoutCache.put(servletPath, pvc);
- }
- return pvc;
- }
- return null;
- }
-}
diff --git a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy
index b9e314ce2c9..0e2f820bf3c 100644
--- a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy
+++ b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy
@@ -16,19 +16,26 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package org.grails.plugins.sitemesh3
+import jakarta.servlet.DispatcherType
+import jakarta.servlet.Filter
+import jakarta.servlet.FilterChain
+import jakarta.servlet.ServletRequest
+import jakarta.servlet.ServletResponse
+
+import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.core.env.ConfigurableEnvironment
import org.springframework.core.env.MapPropertySource
import org.springframework.core.env.PropertySource
+import grails.config.Config
import grails.core.DefaultGrailsApplication
import grails.plugins.Plugin
+import grails.util.Environment
+import grails.util.Metadata
import org.grails.config.PropertySourcesConfig
-import org.grails.gsp.compiler.GroovyPageParser
import org.grails.plugins.web.taglib.RenderSitemeshTagLib
-import org.grails.web.config.http.GrailsFilters
import org.grails.web.util.WebUtils
class Sitemesh3GrailsPlugin extends Plugin {
@@ -48,55 +55,77 @@ class Sitemesh3GrailsPlugin extends Plugin {
def loadBefore = ['groovyPages']
def providedArtefacts = [
- RenderSitemeshTagLib,
+ RenderSitemeshTagLib,
+ Sitemesh3LayoutTagLib,
]
static PropertySource getDefaultPropertySource(ConfigurableEnvironment configurableEnvironment, String defaultLayout) {
-
Map props = [
- 'grails.gsp.view.layoutViewResolver': 'false',
'sitemesh.decorator.metaTag': 'layout',
'sitemesh.decorator.attribute': WebUtils.LAYOUT_ATTRIBUTE,
'sitemesh.decorator.prefix': '/layouts/',
- 'sitemesh.filter.order': GrailsFilters.SITEMESH_FILTER.order,
- 'sitemesh.decorator.tagRuleBundles': ['org.sitemesh.content.tagrules.html.Sm2TagRuleBundle']
]
if (defaultLayout) {
props['sitemesh.decorator.default'] = defaultLayout
}
- // if property already exists, don't override
props.clone().each {
if (configurableEnvironment.getProperty(it.key)) {
props.remove(it.key)
}
}
- return new MapPropertySource('defaultSitemesh3Properties', props)
+ new MapPropertySource('defaultSitemesh3Properties', props)
}
Closure doWithSpring() {
{ ->
ConfigurableEnvironment configurableEnvironment = grailsApplication.mainContext.environment as ConfigurableEnvironment
def propertySources = configurableEnvironment.getPropertySources()
- // https://grails.apache.org/docs/latest/guide/single.html#layouts
- // Default view should be application, but it is inefficient to add a rule for a page that may not exist.
String defaultLayout = grailsApplication.getConfig().getProperty('grails.sitemesh.default.layout')
propertySources.addFirst(getDefaultPropertySource(configurableEnvironment, defaultLayout))
- propertySources.addFirst(new MapPropertySource('requiredSitemesh3Properties', [
- (GroovyPageParser.CONFIG_PROPERTY_GSP_GRAILS_LAYOUT_PREPROCESS): 'false'
- ]))
(grailsApplication as DefaultGrailsApplication).config = new PropertySourcesConfig(propertySources)
- grailsLayoutHandlerMapping(GrailsLayoutHandlerMapping)
+ Config config = grailsApplication.getConfig()
+ boolean developmentMode = Metadata.getCurrent().isDevelopmentEnvironmentAvailable()
+ Environment env = Environment.current
+ boolean enableReload = env.isReloadEnabled() ||
+ config.getProperty('grails.gsp.enable.reload', Boolean, false) ||
+ (developmentMode && env == Environment.DEVELOPMENT)
+ String resolvedDefaultLayout = defaultLayout
+
+ // Bean names match the @ConditionalOnMissingBean(name = "contentProcessor"/"decoratorSelector")
+ // guards on upstream's SiteMeshViewResolverAutoConfiguration, so
+ // our implementations replace upstream's defaults.
+ contentProcessor(CaptureAwareContentProcessor)
+
+ decoratorSelector(Sitemesh3LayoutFinder, ref('groovyPageLocator')) {
+ gspReloadEnabled = enableReload
+ defaultDecoratorName = resolvedDefaultLayout ?: null
+ layoutCacheExpirationMillis = config.getProperty('grails.sitemesh.layout.cache.interval', Long, 5000L)
+ }
+
+ // Replace the filter registration from
+ // org.sitemesh.autoconfigure.SiteMeshAutoConfiguration with a no-op
+ // filter bean under the same name. SiteMeshAutoConfiguration is
+ // @ConditionalOnMissingBean(name = "sitemesh") so registering this
+ // bean disables the upstream filter-based integration entirely.
+ // Decoration is done by the Spring MVC view resolver chain.
+ sitemesh(FilterRegistrationBean) { bean ->
+ bean.autowire = false
+ filter = new NoopSitemeshFilter()
+ enabled = false
+ dispatcherTypes = EnumSet.of(DispatcherType.REQUEST)
+ }
}
}
- void doWithApplicationContext() {}
-
- void doWithDynamicMethods() {}
-
- void onChange(Map event) {}
-
- void onConfigChange(Map event) {}
-
- void onShutdown(Map event) {}
+ // This class is never invoked (the FilterRegistrationBean has enabled = false).
+ // It exists solely so we can register a bean named "sitemesh" and satisfy
+ // SiteMeshAutoConfiguration's @ConditionalOnMissingBean(name = "sitemesh")
+ // guard — preventing the upstream filter-based integration from activating.
+ static class NoopSitemeshFilter implements Filter {
+ @Override
+ void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
+ chain.doFilter(request, response)
+ }
+ }
}
diff --git a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3LayoutTagLib.groovy b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3LayoutTagLib.groovy
new file mode 100644
index 00000000000..35c05072f61
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3LayoutTagLib.groovy
@@ -0,0 +1,262 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3
+
+import groovy.transform.CompileStatic
+
+import jakarta.servlet.http.HttpServletRequest
+
+import grails.artefact.TagLibrary
+import grails.gsp.TagLib
+import org.grails.buffer.FastStringWriter
+import org.grails.buffer.GrailsPrintWriter
+import org.grails.buffer.StreamCharBuffer
+import org.grails.encoder.CodecLookup
+import org.grails.encoder.Encoder
+import org.grails.gsp.compiler.GrailsLayoutPreprocessor
+
+/**
+ * SiteMesh 3 counterpart of {@code GrailsLayoutTagLib}: populates a
+ * {@link Sitemesh3CapturedPage} at GSP render time so that SiteMesh 3 can
+ * decorate without parsing the HTML.
+ *
+ * Registered under the {@code grailsLayout} namespace because the GSP
+ * compile-time preprocessor ({@link GrailsLayoutPreprocessor}) rewrites
+ * {@code
}, {@code }, etc. to {@code }
+ * tags.
+ */
+@CompileStatic
+@TagLib
+class Sitemesh3LayoutTagLib implements TagLibrary {
+
+ static String namespace = 'grailsLayout'
+
+ CodecLookup codecLookup
+
+ def captureTagContent(GrailsPrintWriter writer, String tagname, Map attrs, Object body, boolean noEndTagForEmpty = false) {
+ Object content = null
+ if (body != null) {
+ if (body instanceof Closure) {
+ content = ((Closure) body).call()
+ } else {
+ content = body
+ }
+ }
+
+ if (content instanceof StreamCharBuffer) {
+ ((StreamCharBuffer) content).setPreferSubChunkWhenWritingToOtherBuffer(true)
+ }
+ writer << '<'
+ writer << tagname
+ boolean useXmlClosingForEmptyTag = false
+ if (attrs) {
+ Object xmlClosingString = attrs.remove(GrailsLayoutPreprocessor.XML_CLOSING_FOR_EMPTY_TAG_ATTRIBUTE_NAME)
+ if (xmlClosingString == '/') {
+ useXmlClosingForEmptyTag = true
+ }
+ Encoder htmlEncoder = codecLookup?.lookupEncoder('HTML')
+ attrs.each { k, v ->
+ writer << ' '
+ writer << k
+ writer << '="'
+ writer << (htmlEncoder != null ? htmlEncoder.encode(v) : v)
+ writer << '"'
+ }
+ }
+
+ if (content) {
+ writer << '>'
+ writer << content
+ writer << ''
+ writer << tagname
+ writer << '>'
+ } else {
+ if (!useXmlClosingForEmptyTag) {
+ writer << '>'
+ if (!noEndTagForEmpty) {
+ writer << ''
+ writer << tagname
+ writer << '>'
+ }
+ } else {
+ writer << '/>'
+ }
+ }
+ content
+ }
+
+ StreamCharBuffer wrapContentInBuffer(Object content) {
+ if (content instanceof Closure) {
+ content = ((Closure) content).call()
+ }
+ if (!(content instanceof StreamCharBuffer)) {
+ FastStringWriter stringWriter = new FastStringWriter()
+ stringWriter.print(content)
+ StreamCharBuffer newbuffer = stringWriter.buffer
+ newbuffer.setPreferSubChunkWhenWritingToOtherBuffer(true)
+ return newbuffer
+ }
+ (StreamCharBuffer) content
+ }
+
+ protected Sitemesh3CapturedPage findCapturedPage(HttpServletRequest request) {
+ (Sitemesh3CapturedPage) request.getAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE)
+ }
+
+ /**
+ * Captures the {@code } section of a GSP page into the
+ * {@link Sitemesh3CapturedPage} so the layout can inline it without
+ * re-parsing HTML. Invoked automatically by the GSP compile-time
+ * preprocessor; not intended for direct use in templates.
+ */
+ Closure captureHead = { Map attrs, body ->
+ Object content = captureTagContent(out, 'head', attrs, body)
+ if (content != null) {
+ Sitemesh3CapturedPage page = findCapturedPage(request)
+ if (page != null) {
+ page.setHeadBuffer(wrapContentInBuffer(content))
+ }
+ }
+ }
+
+ /**
+ * Captures the {@code } section of a GSP page, including any
+ * {@code body} attributes (e.g. {@code onload}), into the
+ * {@link Sitemesh3CapturedPage}. Invoked automatically by the GSP
+ * compile-time preprocessor; not intended for direct use in templates.
+ *
+ * @attr onload optional — body onload handler, stored as {@code body.onload}
+ * @attr class optional — body CSS class, stored as {@code body.class}
+ */
+ Closure captureBody = { Map attrs, body ->
+ Object content = captureTagContent(out, 'body', attrs, body)
+ if (content != null) {
+ Sitemesh3CapturedPage page = findCapturedPage(request)
+ if (page != null) {
+ page.setBodyBuffer(wrapContentInBuffer(content))
+ if (attrs) {
+ attrs.each { k, v ->
+ page.addProperty("body.${k?.toString()?.toLowerCase()}".toString(), v?.toString())
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Captures the text content of the {@code } element and stores
+ * it as the {@code title} property of the {@link Sitemesh3CapturedPage}.
+ * Invoked automatically by the GSP compile-time preprocessor; not
+ * intended for direct use in templates.
+ */
+ Closure captureTitle = { Map attrs, body ->
+ Sitemesh3CapturedPage page = findCapturedPage(request)
+ Object content = captureTagContent(out, 'title', attrs, body)
+ if (page != null && content != null) {
+ page.addProperty('title', content.toString())
+ page.setTitleCaptured(true)
+ }
+ }
+
+ /**
+ * Wraps the {@code } tag body in a {@link StreamCharBuffer} and
+ * registers it with the {@link Sitemesh3CapturedPage} so that the layout's
+ * {@code } can render it verbatim. Invoked automatically
+ * by the GSP compile-time preprocessor; not intended for direct use.
+ */
+ Closure wrapTitleTag = { Map attrs, body ->
+ if (body != null) {
+ Sitemesh3CapturedPage page = findCapturedPage(request)
+ if (page != null) {
+ StreamCharBuffer wrapped = wrapContentInBuffer(body)
+ page.setTitleBuffer(wrapped)
+ out << wrapped
+ } else if (body instanceof Closure) {
+ out << ((Closure) body).call()
+ }
+ }
+ }
+
+ /**
+ * Captures {@code } tag attributes and registers them as page
+ * properties (e.g. {@code meta.layout}, {@code meta.author}) on the
+ * {@link Sitemesh3CapturedPage}. Invoked automatically by the GSP
+ * compile-time preprocessor; not intended for direct use in templates.
+ *
+ * @attr name optional — the {@code name} attribute of the meta tag
+ * @attr content optional — the {@code content} attribute value to store
+ * @attr http-equiv optional — the {@code http-equiv} attribute of the meta tag
+ */
+ Closure captureMeta = { Map attrs, body ->
+ captureTagContent(out, 'meta', attrs, body, true)
+ Sitemesh3CapturedPage page = findCapturedPage(request)
+ Object val = attrs?.content
+ if (attrs && page != null && val != null) {
+ String valueStr = val.toString()
+ if (attrs.name) {
+ page.addProperty("meta.${attrs.name}".toString(), valueStr)
+ page.addProperty("meta.${attrs.name.toString().toLowerCase()}".toString(), valueStr)
+ } else if (attrs['http-equiv']) {
+ String httpEquiv = attrs['http-equiv'] as String
+ List httpEquivFormats = [httpEquiv, httpEquiv.toLowerCase()]
+ if (httpEquiv.equalsIgnoreCase('content-type')) {
+ httpEquivFormats << 'Content-Type'
+ }
+ for (String format : httpEquivFormats) {
+ page.addProperty("meta.http-equiv.${format}".toString(), valueStr)
+ }
+ }
+ }
+ }
+
+ /**
+ * Captures a named content block from a GSP page into the
+ * {@link Sitemesh3CapturedPage} so it can be retrieved in the layout
+ * via {@code }. Equivalent to SiteMesh 2's
+ * {@code } but populated at GSP render time rather
+ * than via HTML parsing.
+ *
+ * @attr tag REQUIRED the name of the content block (e.g. {@code navbar})
+ */
+ Closure captureContent = { Map attrs, body ->
+ if (body != null) {
+ Sitemesh3CapturedPage page = findCapturedPage(request)
+ if (page != null && attrs.tag) {
+ page.addContentBuffer(attrs.tag as String, wrapContentInBuffer(body))
+ }
+ }
+ }
+
+ /**
+ * Adds a named parameter to the {@link Sitemesh3CapturedPage}, accessible
+ * in the layout via {@code }.
+ * Equivalent to SiteMesh 2's {@code }.
+ *
+ * @attr name REQUIRED the parameter name
+ * @attr value REQUIRED the parameter value
+ */
+ Closure parameter = { Map attrs, body ->
+ Sitemesh3CapturedPage page = findCapturedPage(request)
+ String name = attrs.name?.toString()
+ String val = attrs.value?.toString()
+ if (page != null && name && val != null) {
+ page.addProperty("page.${name}".toString(), val)
+ }
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/web/taglib/RenderSitemeshTagLib.groovy b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/web/taglib/RenderSitemeshTagLib.groovy
index f502cd20d8b..0b5f1549d2b 100644
--- a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/web/taglib/RenderSitemeshTagLib.groovy
+++ b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/web/taglib/RenderSitemeshTagLib.groovy
@@ -16,60 +16,103 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package org.grails.plugins.web.taglib
import java.nio.CharBuffer
+import org.sitemesh.DecoratorSelector
+import org.sitemesh.SiteMeshContext
import org.sitemesh.content.Content
+import org.sitemesh.content.ContentProcessor
import org.sitemesh.content.ContentProperty
-import org.sitemesh.webapp.SiteMeshFilter
import org.sitemesh.webapp.WebAppContext
import org.sitemesh.webapp.contentfilter.ResponseMetaData
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
-import org.springframework.boot.web.servlet.FilterRegistrationBean
+import org.springframework.context.annotation.Lazy
+import org.springframework.web.servlet.ViewResolver
import grails.artefact.TagLibrary
import grails.gsp.TagLib
+import org.grails.encoder.CodecLookup
+import org.grails.encoder.Encoder
+import org.grails.plugins.sitemesh3.GrailsSiteMeshViewContext
import org.grails.web.util.WebUtils
/**
- * The tags in this library are rendered by sitemesh itself instead of the grails tags so they should always be written
- * as a 'sitemesh' namespace.
+ * Tags rendered by SiteMesh itself (not the Grails taglib pipeline) when a
+ * layout GSP is being processed. Kept in the {@code sitemesh} namespace.
*/
@TagLib
class RenderSitemeshTagLib implements TagLibrary {
- SiteMeshFilter siteMeshFilter
+ @Autowired
+ CodecLookup codecLookup
@Autowired
- RenderSitemeshTagLib(@Qualifier('sitemesh') FilterRegistrationBean sitemesh) {
- this.siteMeshFilter = (SiteMeshFilter) sitemesh.getFilter()
- }
+ ContentProcessor contentProcessor
+ @Autowired
+ DecoratorSelector decoratorSelector
+
+ // Break the circular dependency
+ // RenderSitemeshTagLib -> ViewResolver -> groovyPagesTemplateEngine ->
+ // gspTagLibraryLookup -> RenderSitemeshTagLib by deferring resolution.
+ // @Qualifier is required because the context has several ViewResolver
+ // beans (mvcViewResolver, beanNameViewResolver, groovyMarkupViewResolver,
+ // jspViewResolver, gspViewResolver) and autowiring by type is ambiguous.
+ @Autowired
+ @Lazy
+ @Qualifier('jspViewResolver')
+ ViewResolver viewResolver
+
+ // Dispatches via GrailsSiteMeshViewContext so the layout is rendered
+ // through Spring's View API rather than RequestDispatcher.forward().
+ // Using the default WebAppContext here would re-enter the servlet
+ // pipeline on every call, and nesting (applyLayout
+ // inside applyLayout inside a Sitemesh3LayoutView render) would tear
+ // down the outer request scope before the outer render finished —
+ // causing "request is not active anymore" errors.
Closure applyLayout = { Map attrs, body ->
String savedAttribute = request.getAttribute(WebUtils.LAYOUT_ATTRIBUTE)
- WebAppContext context = new WebAppContext('text/html', request, response,
- servletContext, siteMeshFilter.contentProcessor, new ResponseMetaData(), false)
- Content content = siteMeshFilter.contentProcessor.build(CharBuffer.wrap(body()), context)
- if (attrs.name) {
- request.setAttribute(WebUtils.LAYOUT_ATTRIBUTE, attrs.name)
- }
- String[] decoratorPaths = siteMeshFilter.decoratorSelector.selectDecoratorPaths(content, context)
- for (String decoratorPath : decoratorPaths) {
- content = context.decorate(decoratorPath, content)
+ GrailsSiteMeshViewContext context = new GrailsSiteMeshViewContext(
+ 'text/html', request, response, servletContext,
+ contentProcessor, new ResponseMetaData(), false,
+ viewResolver, request.getLocale())
+ try {
+ Content content = contentProcessor.build(CharBuffer.wrap(body()), context)
+ if (attrs.name) {
+ request.setAttribute(WebUtils.LAYOUT_ATTRIBUTE, attrs.name)
+ }
+ String[] decoratorPaths = decoratorSelector.selectDecoratorPaths(content, context)
+ for (String decoratorPath : decoratorPaths) {
+ Content next = context.decorate(decoratorPath, content)
+ if (next == null) {
+ break
+ }
+ content = next
+ }
+ if (content != null) {
+ content.getData().writeValueTo(out)
+ }
+ } finally {
+ if (savedAttribute != null) {
+ request.setAttribute(WebUtils.LAYOUT_ATTRIBUTE, savedAttribute)
+ } else {
+ request.removeAttribute(WebUtils.LAYOUT_ATTRIBUTE)
+ }
}
- content.getData().writeValueTo(out)
- request.setAttribute(WebUtils.LAYOUT_ATTRIBUTE, savedAttribute)
}
private ContentProperty getContentProperty(String name) {
if (!name) {
return null
}
- Content content = request.getAttribute(WebAppContext.CONTENT_KEY)
+ Content content = (Content) request.getAttribute(WebAppContext.CONTENT_KEY)
+ if (content == null) {
+ return null
+ }
ContentProperty currentProperty = content.getExtractedProperties()
for (String childPropertyName : name.split('\\.')) {
currentProperty = currentProperty.getChild(childPropertyName)
@@ -148,40 +191,45 @@ class RenderSitemeshTagLib implements TagLibrary {
}
}
+ // layoutTitle/layoutHead/layoutBody inline-expand at tag-render time.
+ // This avoids emitting placeholders that would otherwise
+ // require a second HTML parse of the layout output to expand. The
+ // property is pulled directly from the Content being merged (set on the
+ // request under WebAppContext.CONTENT_KEY by WebAppContext.decorate).
Closure layoutTitle = { attrs ->
- out << """${attrs.default ?: ''}""".toString()
+ ContentProperty titleProp = getContentProperty('title')
+ String defaultValue = attrs.default?.toString() ?: ''
+ if (titleProp?.hasValue()) {
+ titleProp.writeValueTo(out)
+ } else if (defaultValue) {
+ out << defaultValue
+ }
}
Closure layoutHead = { attrs, body ->
- StringBuilder tag = new StringBuilder('')
- tag.append(bodyContent)
- tag.append('')
- } else {
- tag.append('/>')
+ ContentProperty headProp = getContentProperty('head')
+ if (headProp?.hasValue()) {
+ headProp.writeValueTo(out)
+ } else if (body) {
+ out << body()
}
- out << tag.toString()
}
Closure layoutBody = { attrs, body ->
- StringBuilder tag = new StringBuilder('')
- tag.append(bodyContent)
- tag.append('')
- } else {
- tag.append('/>')
+ ContentProperty bodyProp = getContentProperty('body')
+ if (bodyProp?.hasValue()) {
+ bodyProp.writeValueTo(out)
+ } else if (body) {
+ out << body()
}
- out << tag.toString()
}
Closure content = { attrs, body ->
- StringBuilder tag = new StringBuilder("""""")
- tag.append(body())
- tag.append('')
- out << tag.toString()
+ Encoder htmlEncoder = codecLookup?.lookupEncoder('HTML')
+ out << ''
+ out << body()
+ out << ''
}
}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/CaptureAwareContentProcessor.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/CaptureAwareContentProcessor.java
new file mode 100644
index 00000000000..9dc2fada835
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/CaptureAwareContentProcessor.java
@@ -0,0 +1,99 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3;
+
+import java.io.IOException;
+import java.nio.CharBuffer;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.sitemesh.SiteMeshContext;
+import org.sitemesh.content.Content;
+import org.sitemesh.content.ContentProcessor;
+import org.sitemesh.content.tagrules.TagBasedContentProcessor;
+import org.sitemesh.content.tagrules.TagRuleBundle;
+import org.sitemesh.content.tagrules.decorate.DecoratorTagRuleBundle;
+import org.sitemesh.content.tagrules.html.CoreHtmlTagRuleBundle;
+import org.sitemesh.content.tagrules.html.Sm2TagRuleBundle;
+import org.sitemesh.webapp.WebAppContext;
+
+/**
+ * {@link ContentProcessor} that short-circuits the HTML parse when a
+ * {@link Sitemesh3CapturedPage} is already populated on the current request
+ * (i.e. the GSP compile-time capture taglib has filled it in). Falls back to a
+ * {@link TagBasedContentProcessor} with the SiteMesh 2 bundle for responses
+ * that were not produced by the capture taglib.
+ */
+public class CaptureAwareContentProcessor implements ContentProcessor {
+
+ private final ContentProcessor fallback;
+
+ public CaptureAwareContentProcessor() {
+ this(new TagBasedContentProcessor(
+ new CoreHtmlTagRuleBundle(),
+ new DecoratorTagRuleBundle(),
+ new Sm2TagRuleBundle()));
+ }
+
+ public CaptureAwareContentProcessor(ContentProcessor fallback) {
+ this.fallback = fallback;
+ }
+
+ public CaptureAwareContentProcessor(TagRuleBundle... bundles) {
+ this(new TagBasedContentProcessor(bundles));
+ }
+
+ @Override
+ public Content build(CharBuffer data, SiteMeshContext context) throws IOException {
+ Sitemesh3CapturedPage captured = findCapturedPage(context);
+
+ // Decoration phase: RenderSitemeshTagLib has already inlined layout
+ // placeholders at tag-render time, so the layout output is final.
+ // If the layout's own capture taglibs populated a page (the common
+ // case when the layout has /), attach the raw layout
+ // output as the page's rendered data and return it — chained
+ // decoration can still read head/body/title properties from the
+ // captured page. Fall back to the parser only when no capture
+ // happened (e.g. a layout with no HTML skeleton).
+ if (context.getContentToMerge() != null) {
+ if (captured != null && captured.isUsed()) {
+ captured.setRenderedContent(data);
+ return captured;
+ }
+ return fallback.build(data, context);
+ }
+
+ if (captured != null && captured.isUsed()) {
+ return captured;
+ }
+ return fallback.build(data, context);
+ }
+
+ private Sitemesh3CapturedPage findCapturedPage(SiteMeshContext context) {
+ if (!(context instanceof WebAppContext)) {
+ return null;
+ }
+ HttpServletRequest request = ((WebAppContext) context).getRequest();
+ if (request == null) {
+ return null;
+ }
+ Object attr = request.getAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE);
+ return attr instanceof Sitemesh3CapturedPage ? (Sitemesh3CapturedPage) attr : null;
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshView.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshView.java
new file mode 100644
index 00000000000..bb974e3fdc4
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshView.java
@@ -0,0 +1,85 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3;
+
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.sitemesh.DecoratorSelector;
+import org.sitemesh.SiteMeshContext;
+import org.sitemesh.content.ContentProcessor;
+import org.sitemesh.webapp.contentfilter.ResponseMetaData;
+import org.sitemesh.webmvc.SiteMeshView;
+import org.sitemesh.webmvc.SiteMeshViewContext;
+
+import org.springframework.web.servlet.View;
+import org.springframework.web.servlet.ViewResolver;
+
+/**
+ * Grails-flavoured {@link SiteMeshView}. Pushes a fresh
+ * {@link Sitemesh3CapturedPage} on the request for the duration of the
+ * main-view render so the GSP capture taglibs (<g:captureHead>
+ * etc.) populate a page attached to the current request. The attribute is
+ * restored on exit.
+ *
+ * Also overrides {@link #createContext} to return a
+ * {@link GrailsSiteMeshViewContext}, which performs the same push/pop
+ * around each decorator dispatch. The combination lets the main view and
+ * each layout level capture into their own {@link Sitemesh3CapturedPage}
+ * without clobbering each other, even for nested
+ * <g:applyLayout>.
+ */
+public class GrailsSiteMeshView extends SiteMeshView {
+
+ public GrailsSiteMeshView(View innerView,
+ ContentProcessor contentProcessor,
+ DecoratorSelector decoratorSelector,
+ ServletContext servletContext,
+ ViewResolver viewResolver) {
+ super(innerView, contentProcessor, decoratorSelector, servletContext, viewResolver);
+ }
+
+ @Override
+ protected Object preRender(HttpServletRequest request) {
+ Object previousCaptured = request.getAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE);
+ request.setAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE, new Sitemesh3CapturedPage());
+ return previousCaptured;
+ }
+
+ @Override
+ protected void postRender(HttpServletRequest request, Object token) {
+ if (token != null) {
+ request.setAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE, token);
+ } else {
+ request.removeAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE);
+ }
+ }
+
+ @Override
+ protected SiteMeshViewContext createContext(HttpServletRequest request,
+ HttpServletResponse response,
+ String contentType,
+ ResponseMetaData metaData) {
+ return new GrailsSiteMeshViewContext(
+ contentType, request, response, getServletContext(),
+ getContentProcessor(), metaData, false,
+ getViewResolver(), request.getLocale());
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewContext.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewContext.java
new file mode 100644
index 00000000000..52651c3b9c2
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewContext.java
@@ -0,0 +1,103 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Locale;
+
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.sitemesh.content.ContentProcessor;
+import org.sitemesh.webapp.contentfilter.ResponseMetaData;
+import org.sitemesh.webmvc.SiteMeshViewContext;
+
+import org.springframework.web.servlet.View;
+import org.springframework.web.servlet.ViewResolver;
+
+/**
+ * Grails-flavoured {@link SiteMeshViewContext} that pushes a fresh
+ * {@link Sitemesh3CapturedPage} onto the request for the duration of each
+ * decorator dispatch. The layout GSP's own capture taglibs populate that
+ * page, which {@link CaptureAwareContentProcessor} then returns as the
+ * decorated {@code Content} (enabling chained decoration without a second
+ * HTML parse).
+ *
+ * Overrides {@link #dispatch} to route absolute paths (those starting with
+ * {@code /}) through the {@link ViewResolver} rather than falling back to
+ * {@code RequestDispatcher.forward()}. All Grails layout paths are absolute
+ * (e.g. {@code /layouts/application}), but {@link SiteMeshViewContext#dispatch}
+ * only uses the ViewResolver for relative names — paths that start with {@code /}
+ * are handed off to {@code WebAppContext.dispatch()} which re-enters the servlet
+ * pipeline via a forward. Routing through the ViewResolver keeps decoration
+ * entirely within the Spring MVC view-resolver chain and avoids re-entering
+ * the filter stack.
+ */
+public class GrailsSiteMeshViewContext extends SiteMeshViewContext {
+
+ public GrailsSiteMeshViewContext(String contentType,
+ HttpServletRequest request,
+ HttpServletResponse response,
+ ServletContext servletContext,
+ ContentProcessor contentProcessor,
+ ResponseMetaData metaData,
+ boolean includeErrorPages,
+ ViewResolver viewResolver,
+ Locale locale) {
+ super(contentType, request, response, servletContext, contentProcessor, metaData,
+ includeErrorPages, viewResolver, locale);
+ }
+
+ @Override
+ public void dispatch(HttpServletRequest request, HttpServletResponse response, String path)
+ throws ServletException, IOException {
+ // Push a fresh Sitemesh3CapturedPage for the decorator render.
+ // DO NOT restore the previous value on exit — chained decoration
+ // relies on reading the freshly-captured page after dispatch returns:
+ // the layout's own taglibs populate it, and
+ // CaptureAwareContentProcessor returns it as the decorated Content
+ // without a second HTML parse. The outer GrailsSiteMeshView.postRender
+ // is responsible for clearing the attribute at the end of the
+ // top-level render.
+ request.setAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE, new Sitemesh3CapturedPage());
+
+ // SiteMeshViewContext.dispatch() only uses the ViewResolver for paths
+ // that do NOT start with "/"; absolute paths fall through to
+ // WebAppContext.dispatch() (a RequestDispatcher.forward()). All Grails
+ // layout paths are absolute (/layouts/...), so we resolve them via the
+ // ViewResolver directly to stay within the Spring MVC view chain.
+ if (path != null && path.startsWith("/")) {
+ try {
+ View view = getViewResolver().resolveViewName(path, getLocale());
+ if (view != null) {
+ view.render(Collections.emptyMap(), request, response);
+ return;
+ }
+ } catch (IOException | ServletException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ServletException("Error rendering layout view: " + path, e);
+ }
+ }
+ super.dispatch(request, response, path);
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java
new file mode 100644
index 00000000000..0c210032e34
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java
@@ -0,0 +1,57 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3;
+
+import jakarta.servlet.ServletContext;
+
+import org.sitemesh.DecoratorSelector;
+import org.sitemesh.SiteMeshContext;
+import org.sitemesh.content.ContentProcessor;
+import org.sitemesh.webmvc.SiteMeshView;
+import org.sitemesh.webmvc.SiteMeshViewResolver;
+
+import org.springframework.web.servlet.View;
+import org.springframework.web.servlet.ViewResolver;
+
+/**
+ * Grails-flavoured {@link SiteMeshViewResolver} that wraps each inner view
+ * with a {@link GrailsSiteMeshView} rather than the upstream default.
+ */
+public class GrailsSiteMeshViewResolver extends SiteMeshViewResolver {
+
+ private final ContentProcessor contentProcessor;
+ private final DecoratorSelector decoratorSelector;
+ private final ServletContext servletContext;
+
+ public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver,
+ ContentProcessor contentProcessor,
+ DecoratorSelector decoratorSelector,
+ ServletContext servletContext) {
+ super(innerViewResolver, contentProcessor, decoratorSelector, servletContext);
+ this.contentProcessor = contentProcessor;
+ this.decoratorSelector = decoratorSelector;
+ this.servletContext = servletContext;
+ }
+
+ @Override
+ protected SiteMeshView createSiteMeshView(View innerView) {
+ return new GrailsSiteMeshView(innerView, contentProcessor, decoratorSelector, servletContext,
+ getInnerViewResolver());
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverBeanPostProcessor.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverBeanPostProcessor.java
new file mode 100644
index 00000000000..77479991bc9
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverBeanPostProcessor.java
@@ -0,0 +1,45 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3;
+
+import org.sitemesh.webmvc.SiteMeshViewResolverBeanPostProcessor;
+
+/**
+ * {@link SiteMeshViewResolverBeanPostProcessor} preconfigured to wrap
+ * Grails' {@code gspViewResolver} bean with a
+ * {@link GrailsSiteMeshViewResolver}.
+ */
+public class GrailsSiteMeshViewResolverBeanPostProcessor extends SiteMeshViewResolverBeanPostProcessor {
+
+ /**
+ * The primary GSP view resolver bean name in Grails. The historical
+ * name {@code jspViewResolver} is kept for compatibility with the
+ * plugin's {@code GroovyPagesPostProcessor}, which registers the GSP
+ * resolver under that name when it isn't already present (see
+ * {@code org.grails.plugins.web.GroovyPagesPostProcessor}). The
+ * modern {@code GspAutoConfiguration.gspViewResolver()} bean is
+ * aliased to this name too when it fires.
+ */
+ public static final String TARGET_VIEW_RESOLVER_BEAN_NAME = "jspViewResolver";
+
+ public GrailsSiteMeshViewResolverBeanPostProcessor() {
+ setTargetViewResolverBeanName(TARGET_VIEW_RESOLVER_BEAN_NAME);
+ setSiteMeshViewResolverClass(GrailsSiteMeshViewResolver.class);
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3AutoConfiguration.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3AutoConfiguration.java
new file mode 100644
index 00000000000..bdef7c450c8
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3AutoConfiguration.java
@@ -0,0 +1,49 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3;
+
+import org.sitemesh.webmvc.SiteMeshViewResolverBeanPostProcessor;
+
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * Registers a {@link GrailsSiteMeshViewResolverBeanPostProcessor} ahead of
+ * the upstream auto-configuration. Upstream's
+ * {@code SiteMeshViewResolverAutoConfiguration} declares its post-processor
+ * bean with {@code @ConditionalOnMissingBean(SiteMeshViewResolverBeanPostProcessor.class)};
+ * by scheduling this config first (via {@link AutoConfigureBefore}), our
+ * Grails-specific subclass is picked up and the default is suppressed.
+ */
+@AutoConfiguration
+@AutoConfigureBefore(name = "org.sitemesh.autoconfigure.SiteMeshViewResolverAutoConfiguration")
+@ConditionalOnClass(SiteMeshViewResolverBeanPostProcessor.class)
+@ConditionalOnProperty(name = "sitemesh.integration", havingValue = "view-resolver")
+public class Sitemesh3AutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean(SiteMeshViewResolverBeanPostProcessor.class)
+ public GrailsSiteMeshViewResolverBeanPostProcessor siteMeshViewResolverBeanPostProcessor() {
+ return new GrailsSiteMeshViewResolverBeanPostProcessor();
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3CapturedPage.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3CapturedPage.java
new file mode 100644
index 00000000000..e05736dea5b
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3CapturedPage.java
@@ -0,0 +1,370 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.CharBuffer;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.sitemesh.content.Content;
+import org.sitemesh.content.ContentChunk;
+import org.sitemesh.content.ContentProperty;
+import org.sitemesh.content.memory.InMemoryContent;
+import org.sitemesh.tagprocessor.CharSequenceBuffer;
+
+import org.grails.buffer.StreamCharBuffer;
+
+/**
+ * A SiteMesh 3 {@link Content} implementation that is populated by the GSP
+ * capture taglib at render time. Because the capture taglib runs during GSP
+ * execution, there is no need for SiteMesh to parse the response body; the
+ * data is already chunked up.
+ *
+ * Backed by an {@link InMemoryContent} so that SiteMesh content properties
+ * can be traversed in the usual way (e.g. {@code head}, {@code body}, {@code
+ * title}, {@code page.}, {@code meta.}).
+ */
+public class Sitemesh3CapturedPage implements Content {
+
+ public static final String REQUEST_ATTRIBUTE = Sitemesh3CapturedPage.class.getName();
+
+ private final InMemoryContent delegate = new InMemoryContent();
+
+ private StreamCharBuffer headBuffer;
+ private StreamCharBuffer bodyBuffer;
+ private StreamCharBuffer titleBuffer;
+ private StreamCharBuffer pageBuffer;
+ private CharSequence renderedContent;
+
+ private final Map contentBuffers = new LinkedHashMap<>();
+ private final Map pageProperties = new HashMap<>();
+
+ // Volatile: a captured page can be passed to an async dispatch thread
+ // (Grails 7 supports @Async controller returns and Callable-returning
+ // actions). Without volatile, the JMM gives no happens-before guarantee
+ // on these flags across threads.
+ private volatile boolean used;
+ private volatile boolean titleCaptured;
+ private volatile boolean propertiesMaterialized;
+
+ public void setHeadBuffer(StreamCharBuffer buffer) {
+ this.headBuffer = buffer;
+ markUsed();
+ }
+
+ public void setBodyBuffer(StreamCharBuffer buffer) {
+ this.bodyBuffer = buffer;
+ markUsed();
+ }
+
+ public void setTitleBuffer(StreamCharBuffer buffer) {
+ this.titleBuffer = buffer;
+ }
+
+ public void setPageBuffer(StreamCharBuffer buffer) {
+ this.pageBuffer = buffer;
+ }
+
+ // Attaches fully-rendered content (e.g. a layout's output after
+ // inline-expanded taglibs have run) as the page's data, bypassing the
+ // HTML parse step that would otherwise build the data from captured
+ // buffers. Held as a CharSequence so callers can pass a CharBuffer
+ // straight through without allocating an intermediate String — the
+ // RawDataChunk writes via Writer.write(char[], int, int) when possible.
+ public void setRenderedContent(CharSequence content) {
+ this.renderedContent = content;
+ markUsed();
+ }
+
+ public StreamCharBuffer getHeadBuffer() {
+ return headBuffer;
+ }
+
+ public StreamCharBuffer getBodyBuffer() {
+ return bodyBuffer;
+ }
+
+ public StreamCharBuffer getTitleBuffer() {
+ return titleBuffer;
+ }
+
+ public StreamCharBuffer getPageBuffer() {
+ return pageBuffer;
+ }
+
+ public void addContentBuffer(String tag, StreamCharBuffer buffer) {
+ contentBuffers.put(tag, buffer);
+ markUsed();
+ }
+
+ public void addProperty(String name, String value) {
+ if (name == null || value == null) {
+ return;
+ }
+ pageProperties.put(name, value);
+ markUsed();
+ }
+
+ public boolean isUsed() {
+ return used;
+ }
+
+ public void markUsed() {
+ this.used = true;
+ }
+
+ public boolean isTitleCaptured() {
+ return titleCaptured;
+ }
+
+ public void setTitleCaptured(boolean titleCaptured) {
+ this.titleCaptured = titleCaptured;
+ }
+
+ /**
+ * Writes the full original page (unmerged) to the given appendable.
+ * Used when decoration is skipped and the caller needs to fall back to
+ * the raw response.
+ */
+ public void writeOriginal(Appendable out) throws IOException {
+ if (pageBuffer != null) {
+ pageBuffer.writeTo(appendableToWriter(out));
+ }
+ }
+
+ @Override
+ public ContentChunk getData() {
+ materializeProperties();
+ if (renderedContent != null) {
+ return new RawDataChunk(renderedContent, this);
+ }
+ return delegate.getData();
+ }
+
+ @Override
+ public ContentProperty getExtractedProperties() {
+ materializeProperties();
+ return delegate.getExtractedProperties();
+ }
+
+ @Override
+ public CharSequenceBuffer createDataOnlyBuffer() {
+ return delegate.createDataOnlyBuffer();
+ }
+
+ private void materializeProperties() {
+ if (propertiesMaterialized) {
+ return;
+ }
+ // Double-checked locking: two async-dispatch threads could both see
+ // propertiesMaterialized == false before either sets it. Without the
+ // synchronized block, both would walk and mutate the InMemoryContent
+ // delegate concurrently, leaving it in a partially-initialized state.
+ synchronized (this) {
+ if (propertiesMaterialized) {
+ return;
+ }
+ propertiesMaterialized = true;
+ doMaterializeProperties();
+ }
+ }
+
+ private void doMaterializeProperties() {
+ ContentProperty root = delegate.getExtractedProperties();
+
+ // pageBuffer is only set for fallback paths where the full rendered
+ // output is wrapped; renderedContent is the hot path (handled by
+ // getData() returning a RawDataChunk directly, no setValue needed).
+ if (pageBuffer != null) {
+ delegate.getData().setValue(pageBuffer);
+ }
+
+ if (headBuffer != null) {
+ // extractHead() strips the via regex, so it materializes
+ // as String — the other captures pass through as CharSequence.
+ root.getChild("head").setValue(extractHead());
+ }
+ if (bodyBuffer != null) {
+ root.getChild("body").setValue(bodyBuffer);
+ }
+ if (titleBuffer != null) {
+ root.getChild("title").setValue(titleBuffer);
+ }
+
+ for (Map.Entry entry : contentBuffers.entrySet()) {
+ root.getChild("page").getChild(entry.getKey()).setValue(entry.getValue());
+ }
+
+ for (Map.Entry entry : pageProperties.entrySet()) {
+ setByDottedName(root, entry.getKey(), entry.getValue());
+ }
+ }
+
+ // Returns the head section without the ... block when
+ // the title was separately captured. Scans the buffer as a CharSequence
+ // rather than materializing it to a String and running a regex —
+ // saves ~head-size bytes of allocation per decorated request, and
+ // avoids regex compilation on the hot path.
+ private CharSequence extractHead() {
+ CharSequence head = headBuffer;
+ if (!titleCaptured) {
+ return head;
+ }
+ int titleStart = indexOfIgnoreCase(head, "', titleStart + 6);
+ if (openTagEnd < 0) {
+ return head;
+ }
+ int closeStart = indexOfIgnoreCase(head, "", openTagEnd + 1);
+ if (closeStart < 0) {
+ return head;
+ }
+ int closeEnd = closeStart + 8;
+ int len = head.length();
+ StringBuilder sb = new StringBuilder(len - (closeEnd - titleStart));
+ sb.append(head, 0, titleStart);
+ sb.append(head, closeEnd, len);
+ return sb;
+ }
+
+ private static int indexOfIgnoreCase(CharSequence seq, String needle, int fromIndex) {
+ int needleLen = needle.length();
+ int max = seq.length() - needleLen;
+ outer:
+ for (int i = fromIndex; i <= max; i++) {
+ for (int j = 0; j < needleLen; j++) {
+ char c = seq.charAt(i + j);
+ char n = needle.charAt(j);
+ if (Character.toLowerCase(c) != Character.toLowerCase(n)) {
+ continue outer;
+ }
+ }
+ return i;
+ }
+ return -1;
+ }
+
+ private static int indexOf(CharSequence seq, char target, int fromIndex) {
+ int len = seq.length();
+ for (int i = fromIndex; i < len; i++) {
+ if (seq.charAt(i) == target) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void setByDottedName(ContentProperty root, String dottedName, String value) {
+ String[] parts = dottedName.split("\\.");
+ ContentProperty current = root;
+ for (String part : parts) {
+ current = current.getChild(part);
+ }
+ current.setValue(value);
+ }
+
+ // ContentChunk whose writeValueTo emits the raw rendered content verbatim
+ // instead of re-walking the property tree (which is InMemoryContent's
+ // default behavior). Used when the captured page carries pre-rendered
+ // layout output that has already had its placeholders inlined.
+ //
+ // Holds the value as CharSequence so no String copy is made at
+ // construction time. writeValueTo takes a fast path through
+ // Writer.write(char[], int, int) when the sequence is a CharBuffer with
+ // an accessible backing array — the common case, since we receive
+ // CharBuffers out of BaseSiteMeshContext's CharArrayWriter. Falls back
+ // to Appendable.append for any other CharSequence shape.
+ private static final class RawDataChunk implements ContentChunk {
+ private CharSequence value;
+ private final Content owner;
+
+ RawDataChunk(CharSequence value, Content owner) {
+ this.value = value;
+ this.owner = owner;
+ }
+
+ @Override
+ public boolean hasValue() {
+ return value != null;
+ }
+
+ @Override
+ public String getValue() {
+ return value == null ? null : value.toString();
+ }
+
+ @Override
+ public String getNonNullValue() {
+ return value == null ? "" : value.toString();
+ }
+
+ @Override
+ public void writeValueTo(Appendable out) throws IOException {
+ if (value == null) {
+ return;
+ }
+ if (out instanceof Writer && value instanceof CharBuffer) {
+ CharBuffer cb = (CharBuffer) value;
+ if (cb.hasArray()) {
+ ((Writer) out).write(cb.array(),
+ cb.arrayOffset() + cb.position(),
+ cb.remaining());
+ return;
+ }
+ }
+ out.append(value);
+ }
+
+ @Override
+ public void setValue(CharSequence newValue) {
+ this.value = newValue;
+ }
+
+ @Override
+ public Content getOwningContent() {
+ return owner;
+ }
+ }
+
+ private static java.io.Writer appendableToWriter(Appendable out) {
+ if (out instanceof java.io.Writer) {
+ return (java.io.Writer) out;
+ }
+ return new java.io.Writer() {
+ @Override
+ public void write(char[] cbuf, int off, int len) throws IOException {
+ out.append(java.nio.CharBuffer.wrap(cbuf, off, len));
+ }
+
+ @Override
+ public void flush() {
+ }
+
+ @Override
+ public void close() {
+ }
+ };
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessor.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessor.java
new file mode 100644
index 00000000000..b43dfa88dcb
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessor.java
@@ -0,0 +1,70 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.env.EnvironmentPostProcessor;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.MapPropertySource;
+
+/**
+ * Seeds default properties consumed by the upstream
+ * {@code SiteMeshViewResolverAutoConfiguration}:
+ *
+ *
+ * - {@code sitemesh.integration=view-resolver} — activates the Spring
+ * MVC {@code ViewResolver} integration instead of the servlet-filter
+ * integration.
+ * - {@code sitemesh.viewResolver.wrapMode=bean-instance} — selects
+ * the live-bean {@code SiteMeshViewResolverBeanPostProcessor}. The
+ * default {@code bean-definition} variant cannot find
+ * {@code gspViewResolver} because its bean definition is registered
+ * after {@code BeanDefinitionRegistryPostProcessors} fire.
+ * - {@code sitemesh.viewResolver.targetBeanName=gspViewResolver} —
+ * tells the post-processor which view resolver to wrap (the default is
+ * {@code jspViewResolver}).
+ *
+ *
+ * Each value is only set when absent so an application can opt-out by
+ * explicitly setting any of these properties.
+ */
+public class Sitemesh3EnvironmentPostProcessor implements EnvironmentPostProcessor {
+
+ public static final String PROPERTY_SOURCE_NAME = "grailsSitemesh3Defaults";
+
+ @Override
+ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
+ Map props = new HashMap<>();
+ if (environment.getProperty("sitemesh.integration") == null) {
+ props.put("sitemesh.integration", "view-resolver");
+ }
+ if (environment.getProperty("sitemesh.viewResolver.wrapMode") == null) {
+ props.put("sitemesh.viewResolver.wrapMode", "bean-instance");
+ }
+ if (environment.getProperty("sitemesh.viewResolver.targetBeanName") == null) {
+ props.put("sitemesh.viewResolver.targetBeanName", "jspViewResolver");
+ }
+ if (!props.isEmpty()) {
+ environment.getPropertySources().addLast(new MapPropertySource(PROPERTY_SOURCE_NAME, props));
+ }
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3LayoutFinder.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3LayoutFinder.java
new file mode 100644
index 00000000000..0c043a51961
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/Sitemesh3LayoutFinder.java
@@ -0,0 +1,263 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import groovy.lang.GroovyObject;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.sitemesh.DecoratorSelector;
+import org.sitemesh.SiteMeshContext;
+import org.sitemesh.content.Content;
+import org.sitemesh.content.ContentProperty;
+import org.sitemesh.webapp.WebAppContext;
+
+import grails.util.Environment;
+import grails.util.GrailsClassUtils;
+import grails.util.GrailsNameUtils;
+import grails.util.GrailsStringUtils;
+import org.grails.core.artefact.ControllerArtefactHandler;
+import org.grails.gsp.io.GroovyPageScriptSource;
+import org.grails.io.support.GrailsResourceUtils;
+import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator;
+import org.grails.web.servlet.mvc.GrailsWebRequest;
+import org.grails.web.util.GrailsApplicationAttributes;
+import org.grails.web.util.WebUtils;
+
+/**
+ * {@link DecoratorSelector} that resolves layout paths using Grails
+ * conventions. The resolution order is:
+ *
+ *
+ * - Request attribute {@link WebUtils#LAYOUT_ATTRIBUTE}
+ * - Content {@code meta.layout} property
+ * - Controller's {@code static layout = 'x'} property
+ * - {@code /layouts//.gsp}
+ * - {@code /layouts/.gsp}
+ * - Configured default (e.g. {@code grails.sitemesh.default.layout})
+ *
+ *
+ * Results are cached by (controllerName, actionUri) outside of the
+ * DEVELOPMENT environment. Both caches are unbounded in entry count,
+ * but are naturally bounded by the number of distinct (controllerName,
+ * actionUri) pairs in the application — typically a small, fixed set.
+ */
+public class Sitemesh3LayoutFinder implements DecoratorSelector {
+
+ private static final String LAYOUTS_PATH = "/layouts";
+
+ private final GrailsConventionGroovyPageLocator groovyPageLocator;
+
+ private String defaultDecoratorName;
+ private boolean gspReloadEnabled;
+ private boolean cacheEnabled = (Environment.getCurrent() != Environment.DEVELOPMENT);
+ // Configurable via grails.sitemesh.layout.cache.interval (milliseconds).
+ // Defaults to 5000 ms. Only consulted when gspReloadEnabled is true.
+ private long layoutCacheExpirationMillis = 5000L;
+
+ private final Map namedDecoratorCache = new ConcurrentHashMap<>();
+ private final Map layoutDecoratorCache = new ConcurrentHashMap<>();
+
+ public Sitemesh3LayoutFinder(GrailsConventionGroovyPageLocator groovyPageLocator) {
+ this.groovyPageLocator = groovyPageLocator;
+ }
+
+ public void setDefaultDecoratorName(String defaultDecoratorName) {
+ this.defaultDecoratorName = defaultDecoratorName;
+ }
+
+ public void setGspReloadEnabled(boolean gspReloadEnabled) {
+ this.gspReloadEnabled = gspReloadEnabled;
+ }
+
+ public void setCacheEnabled(boolean cacheEnabled) {
+ this.cacheEnabled = cacheEnabled;
+ }
+
+ public void setLayoutCacheExpirationMillis(long layoutCacheExpirationMillis) {
+ this.layoutCacheExpirationMillis = layoutCacheExpirationMillis;
+ }
+
+ @Override
+ public String[] selectDecoratorPaths(Content content, SiteMeshContext context) {
+ if (!(context instanceof WebAppContext)) {
+ return toArray(resolveByName(defaultDecoratorName));
+ }
+ HttpServletRequest request = ((WebAppContext) context).getRequest();
+
+ Object layoutAttribute = request.getAttribute(WebUtils.LAYOUT_ATTRIBUTE);
+ String layoutName = layoutAttribute == null ? null : layoutAttribute.toString();
+
+ if (GrailsStringUtils.isBlank(layoutName) && content != null) {
+ layoutName = extractMetaLayout(content);
+ }
+
+ if (!GrailsStringUtils.isBlank(layoutName)) {
+ return resolveMany(layoutName);
+ }
+
+ GroovyObject controller = (GroovyObject) request.getAttribute(GrailsApplicationAttributes.CONTROLLER);
+ if (controller == null) {
+ return toArray(resolveByName(defaultDecoratorName));
+ }
+
+ GrailsWebRequest webRequest = GrailsWebRequest.lookup(request);
+ String controllerName = webRequest != null ? webRequest.getControllerName() : null;
+ if (controllerName == null) {
+ controllerName = GrailsNameUtils.getLogicalPropertyName(controller.getClass().getName(), ControllerArtefactHandler.TYPE);
+ }
+ String actionUri = webRequest != null ? webRequest.getAttributes().getControllerActionUri(request) : null;
+
+ if (controllerName == null || actionUri == null) {
+ return toArray(resolveByName(defaultDecoratorName));
+ }
+
+ LayoutCacheKey cacheKey = null;
+ if (cacheEnabled) {
+ cacheKey = new LayoutCacheKey(controllerName, actionUri);
+ LayoutCacheValue cached = layoutDecoratorCache.get(cacheKey);
+ if (cached != null && (!gspReloadEnabled || !cached.isExpired(layoutCacheExpirationMillis))) {
+ return toArray(cached.path);
+ }
+ }
+
+ String resolved = resolveByConvention(controller, controllerName, actionUri);
+ if (cacheEnabled) {
+ layoutDecoratorCache.put(cacheKey, new LayoutCacheValue(resolved));
+ }
+ return toArray(resolved);
+ }
+
+ private String extractMetaLayout(Content content) {
+ ContentProperty props = content.getExtractedProperties();
+ if (props == null) {
+ return null;
+ }
+ if (!props.hasChild("meta") || !props.getChild("meta").hasChild("layout")) {
+ return null;
+ }
+ return props.getChild("meta").getChild("layout").getValue();
+ }
+
+ private String resolveByConvention(GroovyObject controller, String controllerName, String actionUri) {
+ Object layoutProperty = GrailsClassUtils.getStaticPropertyValue(controller.getClass(), "layout");
+ if (layoutProperty instanceof CharSequence) {
+ return resolveByName(layoutProperty.toString());
+ }
+ if (!GrailsStringUtils.isBlank(actionUri)) {
+ String actionLayout = actionUri.startsWith("/") ? actionUri.substring(1) : actionUri;
+ String resolved = resolveByName(actionLayout);
+ if (resolved != null) {
+ return resolved;
+ }
+ }
+ String resolved = resolveByName(controllerName);
+ if (resolved != null) {
+ return resolved;
+ }
+ return resolveByName(defaultDecoratorName);
+ }
+
+ private String resolveByName(String name) {
+ if (GrailsStringUtils.isBlank(name) || WebUtils.NONE_LAYOUT.equals(name)) {
+ return null;
+ }
+
+ LayoutCacheValue cached = namedDecoratorCache.get(name);
+ if (cacheEnabled && cached != null && (!gspReloadEnabled || !cached.isExpired(layoutCacheExpirationMillis))) {
+ return cached.path;
+ }
+
+ String path = GrailsResourceUtils.cleanPath(GrailsResourceUtils.appendPiecesForUri(LAYOUTS_PATH, name));
+ GroovyPageScriptSource view = groovyPageLocator != null ? groovyPageLocator.findViewByPath(path) : null;
+ String resolved = view != null ? path : null;
+
+ if (cacheEnabled) {
+ namedDecoratorCache.put(name, new LayoutCacheValue(resolved));
+ }
+ return resolved;
+ }
+
+ private static String[] toArray(String value) {
+ return value == null ? new String[0] : new String[] {value};
+ }
+
+ // Handles comma-separated decorator names from meta/attribute sources
+ // (e.g. ), resolving each to a layout
+ // path. Matches SiteMesh 3's convention of applying multiple decorators
+ // in order.
+ private String[] resolveMany(String layoutName) {
+ if (layoutName.indexOf(',') < 0) {
+ return toArray(resolveByName(layoutName));
+ }
+ List resolved = new ArrayList<>();
+ for (String part : layoutName.split(",")) {
+ String trimmed = part.trim();
+ if (GrailsStringUtils.isBlank(trimmed)) {
+ continue;
+ }
+ String path = resolveByName(trimmed);
+ if (path != null) {
+ resolved.add(path);
+ }
+ }
+ return resolved.toArray(new String[0]);
+ }
+
+ private static final class LayoutCacheKey {
+ final String controllerName;
+ final String actionUri;
+
+ LayoutCacheKey(String controllerName, String actionUri) {
+ this.controllerName = controllerName;
+ this.actionUri = actionUri;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof LayoutCacheKey)) return false;
+ LayoutCacheKey that = (LayoutCacheKey) o;
+ return controllerName.equals(that.controllerName) && actionUri.equals(that.actionUri);
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * controllerName.hashCode() + actionUri.hashCode();
+ }
+ }
+
+ private static final class LayoutCacheValue {
+ final String path;
+ final long createdAt = System.currentTimeMillis();
+
+ LayoutCacheValue(String path) {
+ this.path = path;
+ }
+
+ boolean isExpired(long expirationMillis) {
+ return System.currentTimeMillis() - createdAt > expirationMillis;
+ }
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring.factories b/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring.factories
new file mode 100644
index 00000000000..6446d21ad76
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,2 @@
+org.springframework.boot.env.EnvironmentPostProcessor=\
+org.grails.plugins.sitemesh3.Sitemesh3EnvironmentPostProcessor
diff --git a/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 00000000000..8af8d98f50a
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+org.grails.plugins.sitemesh3.Sitemesh3AutoConfiguration
diff --git a/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports b/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports
new file mode 100644
index 00000000000..e4fe91c2486
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports
@@ -0,0 +1 @@
+org.grails.plugins.sitemesh3.Sitemesh3EnvironmentPostProcessor
diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/CaptureAwareContentProcessorSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/CaptureAwareContentProcessorSpec.groovy
new file mode 100644
index 00000000000..d9e7c909282
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/CaptureAwareContentProcessorSpec.groovy
@@ -0,0 +1,134 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3
+
+import java.nio.CharBuffer
+
+import org.grails.buffer.FastStringWriter
+import org.grails.buffer.StreamCharBuffer
+import org.sitemesh.SiteMeshContext
+import org.sitemesh.content.Content
+import org.sitemesh.content.ContentProcessor
+import org.sitemesh.content.memory.InMemoryContent
+import org.sitemesh.webapp.WebAppContext
+
+import org.springframework.mock.web.MockHttpServletRequest
+import spock.lang.Specification
+
+class CaptureAwareContentProcessorSpec extends Specification {
+
+ ContentProcessor fallback
+ CaptureAwareContentProcessor processor
+ MockHttpServletRequest request
+ WebAppContext context
+
+ def setup() {
+ fallback = Mock(ContentProcessor)
+ processor = new CaptureAwareContentProcessor(fallback)
+ request = new MockHttpServletRequest()
+ context = Stub(WebAppContext) {
+ getRequest() >> request
+ getContentToMerge() >> null
+ }
+ }
+
+ void 'returns captured page when populated'() {
+ given:
+ Sitemesh3CapturedPage page = new Sitemesh3CapturedPage()
+ page.setBodyBuffer(bufferOf('hi
'))
+ request.setAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE, page)
+
+ when:
+ Content content = processor.build(CharBuffer.wrap(''), context)
+
+ then:
+ content.is(page)
+ 0 * fallback._
+ }
+
+ void 'falls back to the default processor when no captured page'() {
+ given:
+ Content fallbackContent = new InMemoryContent()
+
+ when:
+ Content content = processor.build(CharBuffer.wrap(''), context)
+
+ then:
+ 1 * fallback.build(_ as CharBuffer, _ as SiteMeshContext) >> fallbackContent
+ content.is(fallbackContent)
+ }
+
+ void 'falls back when captured page is present but unused'() {
+ given:
+ Sitemesh3CapturedPage page = new Sitemesh3CapturedPage()
+ request.setAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE, page)
+ Content fallbackContent = new InMemoryContent()
+
+ when:
+ Content content = processor.build(CharBuffer.wrap(''), context)
+
+ then:
+ 1 * fallback.build(_ as CharBuffer, _ as SiteMeshContext) >> fallbackContent
+ content.is(fallbackContent)
+ }
+
+ void 'decoration phase reuses the layout captured page with rendered content attached'() {
+ given:
+ Sitemesh3CapturedPage layoutPage = new Sitemesh3CapturedPage()
+ layoutPage.setBodyBuffer(bufferOf('from layout'))
+ request.setAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE, layoutPage)
+ Content decorateContext = new InMemoryContent()
+ WebAppContext decorating = Stub(WebAppContext) {
+ getRequest() >> request
+ getContentToMerge() >> decorateContext
+ }
+ String layoutOutput = 'from layout'
+
+ when:
+ Content content = processor.build(CharBuffer.wrap(layoutOutput), decorating)
+
+ then:
+ 0 * fallback._
+ content.is(layoutPage)
+ layoutPage.getData().getValue() == layoutOutput
+ }
+
+ void 'decoration phase falls back to parser when no capture happened'() {
+ given:
+ Content decorateContext = new InMemoryContent()
+ Content fallbackContent = new InMemoryContent()
+ WebAppContext decorating = Stub(WebAppContext) {
+ getRequest() >> request
+ getContentToMerge() >> decorateContext
+ }
+
+ when:
+ Content content = processor.build(CharBuffer.wrap(''), decorating)
+
+ then:
+ 1 * fallback.build(_ as CharBuffer, _ as SiteMeshContext) >> fallbackContent
+ content.is(fallbackContent)
+ }
+
+ private StreamCharBuffer bufferOf(String value) {
+ FastStringWriter writer = new FastStringWriter()
+ writer.print(value)
+ writer.buffer
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewContextSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewContextSpec.groovy
new file mode 100644
index 00000000000..b346648a151
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewContextSpec.groovy
@@ -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
+ *
+ * https://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.grails.plugins.sitemesh3
+
+import java.util.Locale
+
+import org.sitemesh.content.ContentProcessor
+import org.sitemesh.webapp.contentfilter.ResponseMetaData
+
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import org.springframework.mock.web.MockServletContext
+import org.springframework.web.servlet.View
+import org.springframework.web.servlet.ViewResolver
+
+import spock.lang.Specification
+
+class GrailsSiteMeshViewContextSpec extends Specification {
+
+ MockHttpServletRequest request = new MockHttpServletRequest()
+ MockHttpServletResponse response = new MockHttpServletResponse()
+ MockServletContext servletContext = new MockServletContext()
+ ContentProcessor contentProcessor = Mock(ContentProcessor)
+ ViewResolver viewResolver = Mock(ViewResolver)
+
+ private GrailsSiteMeshViewContext newContext() {
+ new GrailsSiteMeshViewContext(
+ 'text/html', request, response, servletContext,
+ contentProcessor, new ResponseMetaData(), false,
+ viewResolver, Locale.ENGLISH)
+ }
+
+ void "dispatch pushes a fresh Sitemesh3CapturedPage for the decorator render"() {
+ given:
+ Sitemesh3CapturedPage existing = new Sitemesh3CapturedPage()
+ existing.markUsed()
+ request.setAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE, existing)
+ View view = Mock(View)
+ viewResolver.resolveViewName('/layouts/foo', Locale.ENGLISH) >> view
+
+ when:
+ newContext().dispatch(request, response, '/layouts/foo')
+
+ then:
+ def captured = request.getAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE) as Sitemesh3CapturedPage
+ captured != null
+ captured.is(existing) == false
+ captured.isUsed() == false
+ }
+
+ void "dispatch leaves the fresh captured page on the request for chained decoration"() {
+ given:
+ request.setAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE, new Sitemesh3CapturedPage())
+ View view = Mock(View)
+ viewResolver.resolveViewName('/layouts/foo', Locale.ENGLISH) >> view
+
+ when:
+ newContext().dispatch(request, response, '/layouts/foo')
+
+ then: 'no restore — chained decoration reads this after dispatch'
+ request.getAttribute(Sitemesh3CapturedPage.REQUEST_ATTRIBUTE) instanceof Sitemesh3CapturedPage
+ }
+
+ void "dispatch resolves the layout path through the supplied ViewResolver"() {
+ given:
+ View view = Mock(View)
+ viewResolver.resolveViewName('/layouts/custom', Locale.ENGLISH) >> view
+
+ when:
+ newContext().dispatch(request, response, '/layouts/custom')
+
+ then:
+ 1 * view.render(_, request, response)
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverBeanPostProcessorSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverBeanPostProcessorSpec.groovy
new file mode 100644
index 00000000000..550c9faa11c
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverBeanPostProcessorSpec.groovy
@@ -0,0 +1,71 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3
+
+import jakarta.servlet.ServletContext
+
+import org.sitemesh.DecoratorSelector
+import org.sitemesh.SiteMeshContext
+import org.sitemesh.content.ContentProcessor
+
+import org.springframework.beans.factory.BeanFactory
+import org.springframework.web.servlet.ViewResolver
+import org.springframework.web.servlet.view.InternalResourceViewResolver
+
+import spock.lang.Specification
+
+class GrailsSiteMeshViewResolverBeanPostProcessorSpec extends Specification {
+
+ BeanFactory beanFactory = Mock(BeanFactory)
+
+ void "target bean name defaults to jspViewResolver and wrapper class is GrailsSiteMeshViewResolver"() {
+ expect:
+ new GrailsSiteMeshViewResolverBeanPostProcessor().targetViewResolverBeanName == 'jspViewResolver'
+ new GrailsSiteMeshViewResolverBeanPostProcessor().siteMeshViewResolverClass == GrailsSiteMeshViewResolver
+ }
+
+ void "wraps the jspViewResolver bean with GrailsSiteMeshViewResolver"() {
+ given:
+ ContentProcessor cp = Mock(ContentProcessor)
+ DecoratorSelector ds = Mock(DecoratorSelector)
+ ServletContext sc = Mock(ServletContext)
+ beanFactory.getBean('contentProcessor', ContentProcessor) >> cp
+ beanFactory.getBean('decoratorSelector', DecoratorSelector) >> ds
+ beanFactory.getBean('servletContext', ServletContext) >> sc
+
+ GrailsSiteMeshViewResolverBeanPostProcessor pp = new GrailsSiteMeshViewResolverBeanPostProcessor()
+ pp.setBeanFactory(beanFactory)
+
+ when:
+ Object result = pp.postProcessAfterInitialization(new InternalResourceViewResolver(), 'jspViewResolver')
+
+ then:
+ result instanceof GrailsSiteMeshViewResolver
+ }
+
+ void "beans with a non-matching name are returned untouched"() {
+ given:
+ GrailsSiteMeshViewResolverBeanPostProcessor pp = new GrailsSiteMeshViewResolverBeanPostProcessor()
+ pp.setBeanFactory(beanFactory)
+ ViewResolver other = new InternalResourceViewResolver()
+
+ expect:
+ pp.postProcessAfterInitialization(other, 'someOther').is(other)
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverSpec.groovy
new file mode 100644
index 00000000000..b4f347d05b6
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverSpec.groovy
@@ -0,0 +1,89 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3
+
+import java.util.Locale
+
+import jakarta.servlet.ServletContext
+
+import org.sitemesh.DecoratorSelector
+import org.sitemesh.SiteMeshContext
+import org.sitemesh.content.ContentProcessor
+
+import org.springframework.web.servlet.View
+import org.springframework.web.servlet.ViewResolver
+import org.springframework.web.servlet.view.RedirectView
+
+import spock.lang.Specification
+
+class GrailsSiteMeshViewResolverSpec extends Specification {
+
+ ViewResolver inner = Mock(ViewResolver)
+ ContentProcessor contentProcessor = Mock(ContentProcessor)
+ DecoratorSelector decoratorSelector = Mock(DecoratorSelector)
+ ServletContext servletContext = Mock(ServletContext)
+
+ GrailsSiteMeshViewResolver resolver() {
+ new GrailsSiteMeshViewResolver(inner, contentProcessor, decoratorSelector, servletContext)
+ }
+
+ void "resolveViewName wraps the inner view in a GrailsSiteMeshView"() {
+ given:
+ View innerView = Mock(View)
+ inner.resolveViewName('/foo/bar', Locale.ENGLISH) >> innerView
+
+ when:
+ View result = resolver().resolveViewName('/foo/bar', Locale.ENGLISH)
+
+ then:
+ result instanceof GrailsSiteMeshView
+ }
+
+ void "layout paths are passed through without wrapping"() {
+ given:
+ View innerView = Mock(View)
+ inner.resolveViewName('/layouts/main', Locale.ENGLISH) >> innerView
+
+ when:
+ View result = resolver().resolveViewName('/layouts/main', Locale.ENGLISH)
+
+ then:
+ result.is(innerView)
+ }
+
+ void "redirect views are passed through without wrapping"() {
+ given:
+ RedirectView redirect = new RedirectView('/target')
+ inner.resolveViewName('/goto', Locale.ENGLISH) >> redirect
+
+ when:
+ View result = resolver().resolveViewName('/goto', Locale.ENGLISH)
+
+ then:
+ result.is(redirect)
+ }
+
+ void "null inner view yields null"() {
+ given:
+ inner.resolveViewName('/missing', Locale.ENGLISH) >> null
+
+ expect:
+ resolver().resolveViewName('/missing', Locale.ENGLISH) == null
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessorSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessorSpec.groovy
new file mode 100644
index 00000000000..a0d3ac6fe59
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessorSpec.groovy
@@ -0,0 +1,71 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3
+
+import org.springframework.boot.SpringApplication
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.MutablePropertySources
+import org.springframework.mock.env.MockEnvironment
+
+import spock.lang.Specification
+
+class Sitemesh3EnvironmentPostProcessorSpec extends Specification {
+
+ Sitemesh3EnvironmentPostProcessor pp = new Sitemesh3EnvironmentPostProcessor()
+
+ void "seeds sitemesh defaults when nothing is configured"() {
+ given:
+ MockEnvironment env = new MockEnvironment()
+
+ when:
+ pp.postProcessEnvironment(env, new SpringApplication())
+
+ then:
+ env.getProperty('sitemesh.integration') == 'view-resolver'
+ env.getProperty('sitemesh.viewResolver.wrapMode') == 'bean-instance'
+ env.getProperty('sitemesh.viewResolver.targetBeanName') == 'jspViewResolver'
+ }
+
+ void "respects existing user values"() {
+ given:
+ MockEnvironment env = new MockEnvironment()
+ env.setProperty('sitemesh.integration', 'filter')
+ env.setProperty('sitemesh.viewResolver.wrapMode', 'bean-definition')
+ env.setProperty('sitemesh.viewResolver.targetBeanName', 'myResolver')
+
+ when:
+ pp.postProcessEnvironment(env, new SpringApplication())
+
+ then: 'user values win'
+ env.getProperty('sitemesh.integration') == 'filter'
+ env.getProperty('sitemesh.viewResolver.wrapMode') == 'bean-definition'
+ env.getProperty('sitemesh.viewResolver.targetBeanName') == 'myResolver'
+ }
+
+ void "registered property source has the expected name"() {
+ given:
+ MockEnvironment env = new MockEnvironment()
+
+ when:
+ pp.postProcessEnvironment(env, new SpringApplication())
+
+ then:
+ env.getPropertySources().contains(Sitemesh3EnvironmentPostProcessor.PROPERTY_SOURCE_NAME)
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3LayoutFinderSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3LayoutFinderSpec.groovy
new file mode 100644
index 00000000000..5f443ca1b3d
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3LayoutFinderSpec.groovy
@@ -0,0 +1,199 @@
+/*
+ * 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
+ *
+ * https://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.grails.plugins.sitemesh3
+
+import org.sitemesh.content.Content
+import org.sitemesh.content.ContentProperty
+import org.sitemesh.content.memory.InMemoryContent
+import org.sitemesh.webapp.WebAppContext
+
+import org.grails.gsp.io.GroovyPageScriptSource
+import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator
+import org.grails.web.servlet.mvc.GrailsWebRequest
+import org.grails.web.util.GrailsApplicationAttributes
+import org.grails.web.util.WebUtils
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import org.springframework.mock.web.MockServletContext
+import org.springframework.web.context.request.RequestContextHolder
+
+import spock.lang.Specification
+
+class Sitemesh3LayoutFinderSpec extends Specification {
+
+ GrailsConventionGroovyPageLocator locator
+ Sitemesh3LayoutFinder finder
+ WebAppContext context
+ MockHttpServletRequest request
+ MockHttpServletResponse response
+ MockServletContext servletContext
+
+ def setup() {
+ locator = Mock(GrailsConventionGroovyPageLocator)
+ finder = new Sitemesh3LayoutFinder(locator)
+ finder.cacheEnabled = false
+ request = new MockHttpServletRequest()
+ response = new MockHttpServletResponse()
+ servletContext = new MockServletContext()
+ context = Stub(WebAppContext) {
+ getRequest() >> request
+ }
+ }
+
+ def cleanup() {
+ RequestContextHolder.resetRequestAttributes()
+ }
+
+ void 'request attribute wins over all other sources'() {
+ given:
+ request.setAttribute(WebUtils.LAYOUT_ATTRIBUTE, 'chosen')
+ Content content = contentWithMetaLayout('ignored')
+
+ when:
+ String[] paths = finder.selectDecoratorPaths(content, context)
+
+ then:
+ 1 * locator.findViewByPath('/layouts/chosen') >> Mock(GroovyPageScriptSource)
+ paths == ['/layouts/chosen'] as String[]
+ }
+
+ void 'meta layout is used when request attribute is blank'() {
+ given:
+ Content content = contentWithMetaLayout('meta-layout')
+
+ when:
+ String[] paths = finder.selectDecoratorPaths(content, context)
+
+ then:
+ 1 * locator.findViewByPath('/layouts/meta-layout') >> Mock(GroovyPageScriptSource)
+ paths == ['/layouts/meta-layout'] as String[]
+ }
+
+ void 'falls back to configured default when no controller is bound'() {
+ given:
+ finder.defaultDecoratorName = 'application'
+ Content content = emptyContent()
+
+ when:
+ String[] paths = finder.selectDecoratorPaths(content, context)
+
+ then:
+ 1 * locator.findViewByPath('/layouts/application') >> Mock(GroovyPageScriptSource)
+ paths == ['/layouts/application'] as String[]
+ }
+
+ void 'controller static layout property is used next'() {
+ given:
+ bindController(new FixedLayoutController(), 'sample', '/sample/index')
+ Content content = emptyContent()
+
+ when:
+ String[] paths = finder.selectDecoratorPaths(content, context)
+
+ then:
+ 1 * locator.findViewByPath('/layouts/controller-static') >> Mock(GroovyPageScriptSource)
+ paths == ['/layouts/controller-static'] as String[]
+ }
+
+ void 'action-specific layout is tried when no static layout'() {
+ given:
+ bindController(new ConventionController(), 'sample', '/sample/edit')
+ Content content = emptyContent()
+
+ when:
+ String[] paths = finder.selectDecoratorPaths(content, context)
+
+ then:
+ 1 * locator.findViewByPath('/layouts/sample/edit') >> Mock(GroovyPageScriptSource)
+ 0 * locator.findViewByPath(_)
+ paths == ['/layouts/sample/edit'] as String[]
+ }
+
+ void 'controller-specific layout is tried when action not found'() {
+ given:
+ bindController(new ConventionController(), 'sample', '/sample/edit')
+ Content content = emptyContent()
+
+ when:
+ String[] paths = finder.selectDecoratorPaths(content, context)
+
+ then:
+ 1 * locator.findViewByPath('/layouts/sample/edit') >> null
+ 1 * locator.findViewByPath('/layouts/sample') >> Mock(GroovyPageScriptSource)
+ paths == ['/layouts/sample'] as String[]
+ }
+
+ void 'default layout is used when nothing else matches'() {
+ given:
+ finder.defaultDecoratorName = 'application'
+ bindController(new ConventionController(), 'sample', '/sample/edit')
+ Content content = emptyContent()
+
+ when:
+ String[] paths = finder.selectDecoratorPaths(content, context)
+
+ then:
+ 1 * locator.findViewByPath('/layouts/sample/edit') >> null
+ 1 * locator.findViewByPath('/layouts/sample') >> null
+ 1 * locator.findViewByPath('/layouts/application') >> Mock(GroovyPageScriptSource)
+ paths == ['/layouts/application'] as String[]
+ }
+
+ void 'returns empty when nothing matches and no default'() {
+ given:
+ bindController(new ConventionController(), 'sample', '/sample/edit')
+ Content content = emptyContent()
+
+ when:
+ String[] paths = finder.selectDecoratorPaths(content, context)
+
+ then:
+ 1 * locator.findViewByPath('/layouts/sample/edit') >> null
+ 1 * locator.findViewByPath('/layouts/sample') >> null
+ paths.length == 0
+ }
+
+ private Content contentWithMetaLayout(String layout) {
+ Content content = new InMemoryContent()
+ ContentProperty root = content.getExtractedProperties()
+ root.getChild('meta').getChild('layout').setValue(layout)
+ content
+ }
+
+ private Content emptyContent() {
+ new InMemoryContent()
+ }
+
+ private void bindController(controller, String controllerName, String actionUri) {
+ GrailsWebRequest webRequest = new GrailsWebRequest(request, response, servletContext)
+ RequestContextHolder.setRequestAttributes(webRequest)
+ webRequest.setControllerName(controllerName)
+ request.setAttribute(GrailsApplicationAttributes.CONTROLLER, controller)
+ controller.actionUri = actionUri
+ }
+
+ static class ConventionController {
+ String actionUri
+ }
+
+ static class FixedLayoutController {
+ static String layout = 'controller-static'
+ String actionUri
+ }
+}
diff --git a/grails-gsp/spring-boot/src/main/java/grails/gsp/boot/GspAutoConfiguration.java b/grails-gsp/spring-boot/src/main/java/grails/gsp/boot/GspAutoConfiguration.java
index bc2b38a3e6f..422d796c683 100644
--- a/grails-gsp/spring-boot/src/main/java/grails/gsp/boot/GspAutoConfiguration.java
+++ b/grails-gsp/spring-boot/src/main/java/grails/gsp/boot/GspAutoConfiguration.java
@@ -27,8 +27,6 @@
import jakarta.servlet.ServletContext;
-import org.sitemesh.autoconfigure.SiteMeshAutoConfiguration;
-
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -39,7 +37,6 @@
import org.springframework.beans.factory.support.GenericBeanDefinition;
import org.springframework.beans.factory.support.ManagedList;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
-import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
@@ -65,7 +62,6 @@
import org.grails.gsp.io.GroovyPageScriptSource;
import org.grails.gsp.jsp.TagLibraryResolver;
import org.grails.gsp.jsp.TagLibraryResolverImpl;
-import org.grails.plugins.sitemesh3.GrailsLayoutHandlerMapping;
import org.grails.plugins.sitemesh3.Sitemesh3GrailsPlugin;
import org.grails.plugins.web.taglib.RenderSitemeshTagLib;
import org.grails.plugins.web.taglib.RenderTagLib;
@@ -93,11 +89,6 @@ protected static abstract class AbstractGspConfig {
boolean sitemesh3;
}
- @Bean
- GrailsLayoutHandlerMapping grailsLayoutHandlerMapping() {
- return new GrailsLayoutHandlerMapping();
- }
-
@Configuration
@Import({TagLibraryLookupRegistrar.class, RemoveDefaultViewResolverRegistrar.class})
protected static class GspTemplateEngineAutoConfiguration extends AbstractGspConfig {
@@ -193,7 +184,6 @@ GroovyPagesTemplateRenderer groovyPagesTemplateRenderer(GrailsConventionGroovyPa
}
@Configuration
- @AutoConfigureBefore(SiteMeshAutoConfiguration.class)
@ConditionalOnMissingBean(name = "sitemesh3")
protected static class Sitemesh3Configuration implements EnvironmentAware, BeanDefinitionRegistryPostProcessor {
@Override
diff --git a/grails-test-examples/gsp-sitemesh3/build.gradle b/grails-test-examples/gsp-sitemesh3/build.gradle
index 6a0afc44682..50bf1b18a5d 100644
--- a/grails-test-examples/gsp-sitemesh3/build.gradle
+++ b/grails-test-examples/gsp-sitemesh3/build.gradle
@@ -42,6 +42,7 @@ dependencies {
implementation 'org.apache.grails:grails-controllers'
implementation 'org.apache.grails:grails-rest-transforms'
implementation 'org.apache.grails:grails-sitemesh3'
+ implementation 'org.apache.grails:grails-async'
testAndDevelopmentOnly platform(project(':grails-bom'))
testAndDevelopmentOnly 'org.webjars.npm:bootstrap'
diff --git a/grails-test-examples/gsp-sitemesh3/grails-app/controllers/org/example/grails/layout/EndToEndController.groovy b/grails-test-examples/gsp-sitemesh3/grails-app/controllers/org/example/grails/layout/EndToEndController.groovy
index 1c1320acf44..d6a8161ee93 100644
--- a/grails-test-examples/gsp-sitemesh3/grails-app/controllers/org/example/grails/layout/EndToEndController.groovy
+++ b/grails-test-examples/gsp-sitemesh3/grails-app/controllers/org/example/grails/layout/EndToEndController.groovy
@@ -19,12 +19,26 @@
package org.example.grails.layout
+import static grails.async.web.WebPromises.task
+
class EndToEndController {
def simpleLayout() {
render view: 'simple', layout: 'simple'
}
+ def asyncSimpleLayout() {
+ task {
+ render view: 'simple', layout: 'simple'
+ }
+ }
+
+ def asyncMultipleLevelsOfLayouts() {
+ task {
+ render view: 'multipleLevelsOfLayouts', layout: 'simple'
+ }
+ }
+
def titleInSubtemplate() {
render view: 'titleInSubtemplate', layout: 'simple'
}
diff --git a/grails-test-examples/gsp-sitemesh3/src/integration-test/groovy/EndToEndSpec.groovy b/grails-test-examples/gsp-sitemesh3/src/integration-test/groovy/EndToEndSpec.groovy
index df9dba72322..f15caa6bf74 100644
--- a/grails-test-examples/gsp-sitemesh3/src/integration-test/groovy/EndToEndSpec.groovy
+++ b/grails-test-examples/gsp-sitemesh3/src/integration-test/groovy/EndToEndSpec.groovy
@@ -19,7 +19,6 @@
import grails.plugin.geb.ContainerGebSpec
import grails.testing.mixin.integration.Integration
-import spock.lang.PendingFeature
@Integration
class EndToEndSpec extends ContainerGebSpec {
@@ -45,7 +44,6 @@ class EndToEndSpec extends ContainerGebSpec {
"""
}
- @PendingFeature
def 'multiple levels of layouts'() {
when:
go('endToEnd/multipleLevelsOfLayouts')
@@ -82,6 +80,32 @@ class EndToEndSpec extends ContainerGebSpec {
Hello
body text
+"""
+ }
+
+ // The async dispatch returns on a different thread than the original
+ // request — exercises Sitemesh3CapturedPage.propertiesMaterialized
+ // cross-thread visibility (the volatile field).
+ def 'async simple layout'() {
+ when:
+ go('endToEnd/asyncSimpleLayout')
+
+ then:
+ pageSource == """Decorated This is the title
+Hello
body text
+"""
+ }
+
+ // Async dispatch + nested — covers both the volatile
+ // captured-page field and the ViewResolver-based dispatch that replaced
+ // RequestDispatcher.forward() for nested layouts.
+ def 'async multiple levels of layouts'() {
+ when:
+ go('endToEnd/asyncMultipleLevelsOfLayouts')
+
+ then:
+ pageSource == """Decorated Base - Dialog - This is the title
+Hello
"""
}
}