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 << '' + } else { + if (!useXmlClosingForEmptyTag) { + writer << '>' + if (!noEndTagForEmpty) { + 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 <title>} tag body in a {@link StreamCharBuffer} and + * registers it with the {@link Sitemesh3CapturedPage} so that the layout's + * {@code <g:layoutTitle>} 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 <meta>} 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<String> 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 <g:pageProperty name="page.<tag>">}. Equivalent to SiteMesh 2's + * {@code <content tag="...">} 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 <g:pageProperty name="page.<name>">}. + * Equivalent to SiteMesh 2's {@code <parameter name="..." value="..."/>}. + * + * @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<SiteMeshContext> 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 <g:applyLayout> 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 <sitemesh:write> 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 << """<sitemesh:write property="title">${attrs.default ?: ''}</sitemesh:write>""".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('<sitemesh:write property="head"') - String bodyContent = body() - if (bodyContent) { - tag.append('>') - tag.append(bodyContent) - tag.append('</sitemesh:write>') - } 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('<sitemesh:write property="body"') - String bodyContent = body() - if (bodyContent) { - tag.append('>') - tag.append(bodyContent) - tag.append('</sitemesh:write>') - } 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("""<content tag="${attrs.tag}">""") - tag.append(body()) - tag.append('</content>') - out << tag.toString() + Encoder htmlEncoder = codecLookup?.lookupEncoder('HTML') + out << '<content tag="' + out << (htmlEncoder != null ? htmlEncoder.encode(attrs.tag) : attrs.tag) + out << '">' + out << body() + out << '</content>' } } 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 <head>/<body>), 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 (<code><g:captureHead></code> + * etc.) populate a page attached to the current request. The attribute is + * restored on exit. + * + * <p>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 + * <code><g:applyLayout></code>.</p> + */ +public class GrailsSiteMeshView extends SiteMeshView { + + public GrailsSiteMeshView(View innerView, + ContentProcessor contentProcessor, + DecoratorSelector<SiteMeshContext> 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). + * + * <p>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.</p> + */ +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 <g:capture*> 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<SiteMeshContext> decoratorSelector; + private final ServletContext servletContext; + + public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver, + ContentProcessor contentProcessor, + DecoratorSelector<SiteMeshContext> 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. + * + * <p>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.<name>}, {@code meta.<name>}).</p> + */ +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<String, StreamCharBuffer> contentBuffers = new LinkedHashMap<>(); + private final Map<String, String> 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 <title> 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<String, StreamCharBuffer> entry : contentBuffers.entrySet()) { + root.getChild("page").getChild(entry.getKey()).setValue(entry.getValue()); + } + + for (Map.Entry<String, String> entry : pageProperties.entrySet()) { + setByDottedName(root, entry.getKey(), entry.getValue()); + } + } + + // Returns the head section without the <title>... 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: + * + *
    + *
  1. Request attribute {@link WebUtils#LAYOUT_ATTRIBUTE}
  2. + *
  3. Content {@code meta.layout} property
  4. + *
  5. Controller's {@code static layout = 'x'} property
  6. + *
  7. {@code /layouts//.gsp}
  8. + *
  9. {@code /layouts/.gsp}
  10. + *
  11. Configured default (e.g. {@code grails.sitemesh.default.layout})
  12. + *
+ * + *

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

body text
""" } }