From b0e0990395e5def7766381514412abab6cd9c6f2 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 2 Jun 2026 13:13:01 -0700 Subject: [PATCH 1/5] Make SiteMesh 3 the default GSP layout, mutually exclusive with grails-layout Introduce a GspLayout one-of feature group so SiteMesh 3 (grails-sitemesh3) and the legacy SiteMesh 2 grails-layout are both selectable but never applied together (enforced by OneOfFeatureValidator): - GspLayout: abstract OneOfFeature parent (Category.VIEW), WEB/WEB_PLUGIN - Sitemesh3: default member; auto-applied unless another GspLayout is selected; adds grails-sitemesh3 - GrailsLayout: opt-in member; adds grails-layout (SiteMesh 2) The GspLayout features now own the layout dependency, so GrailsGsp's 'if (!isFeaturePresent(Sitemesh3)) add grails-layout' block is removed. Selecting both sitemesh3 and grails-layout now fails fast. --- .../forge/feature/sitemesh3/Sitemesh3.java | 36 +++++----- .../grails/forge/feature/view/GrailsGsp.java | 7 -- .../forge/feature/view/GrailsLayout.java | 55 ++++++++++++++ .../grails/forge/feature/view/GspLayout.java | 47 ++++++++++++ .../forge/feature/view/GspLayoutSpec.groovy | 72 +++++++++++++++++++ 5 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsLayout.java create mode 100644 grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GspLayout.java create mode 100644 grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GspLayoutSpec.groovy diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/sitemesh3/Sitemesh3.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/sitemesh3/Sitemesh3.java index 73fd1dc66a3..7d5fcddb2fd 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/sitemesh3/Sitemesh3.java +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/sitemesh3/Sitemesh3.java @@ -22,14 +22,20 @@ import org.grails.forge.application.ApplicationType; import org.grails.forge.application.generator.GeneratorContext; import org.grails.forge.build.dependencies.Dependency; -import org.grails.forge.feature.Category; +import org.grails.forge.feature.DefaultFeature; import org.grails.forge.feature.Feature; +import org.grails.forge.feature.view.GspLayout; +import org.grails.forge.options.Options; -@Singleton -public class Sitemesh3 implements Feature { +import java.util.Set; - public Sitemesh3() { - } +/** + * Default GSP layout decorator, backed by SiteMesh 3 ({@code grails-sitemesh3}). + * Applied automatically to web applications unless another {@link GspLayout} + * (e.g. {@code grails-layout}) is explicitly selected. + */ +@Singleton +public class Sitemesh3 extends GspLayout implements DefaultFeature { @Override public String getName() { @@ -38,12 +44,18 @@ public String getName() { @Override public String getTitle() { - return "Sitemesh 3"; + return "SiteMesh 3"; } @Override public String getDescription() { - return "Adds support for Sitemesh3 based layouts instead of Sitemesh 2"; + return "Adds support for SiteMesh 3 based GSP layouts"; + } + + @Override + public boolean shouldApply(ApplicationType applicationType, Options options, Set selectedFeatures) { + return supports(applicationType) && + selectedFeatures.stream().noneMatch(GspLayout.class::isInstance); } @Override @@ -53,14 +65,4 @@ public void apply(GeneratorContext generatorContext) { .artifactId("grails-sitemesh3") .implementation()); } - - @Override - public boolean supports(ApplicationType applicationType) { - return true; - } - - @Override - public String getCategory() { - return Category.VIEW; - } } diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsGsp.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsGsp.java index 207777d7a29..3960b754f8c 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsGsp.java +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsGsp.java @@ -27,7 +27,6 @@ import org.grails.forge.feature.DefaultFeature; import org.grails.forge.feature.Feature; import org.grails.forge.feature.FeatureContext; -import org.grails.forge.feature.sitemesh3.Sitemesh3; import org.grails.forge.feature.web.GrailsWeb; import org.grails.forge.options.Options; import org.grails.forge.template.URLTemplate; @@ -110,12 +109,6 @@ public void apply(GeneratorContext generatorContext) { .groupId("org.apache.grails") .artifactId("grails-gsp") .implementation()); - if (!generatorContext.isFeaturePresent(Sitemesh3.class)) { - generatorContext.addDependency(Dependency.builder() - .groupId("org.apache.grails") - .artifactId("grails-layout") - .implementation()); - } final ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); generatorContext.addTemplate("mainLayout", new URLTemplate(getViewFolderPath() + "layouts/main.gsp", classLoader.getResource("gsp/main.gsp"))); diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsLayout.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsLayout.java new file mode 100644 index 00000000000..5bd2b958b9c --- /dev/null +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsLayout.java @@ -0,0 +1,55 @@ +/* + * 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.forge.feature.view; + +import jakarta.inject.Singleton; +import org.grails.forge.application.generator.GeneratorContext; +import org.grails.forge.build.dependencies.Dependency; + +/** + * Opt-in GSP layout decorator backed by the legacy SiteMesh 2 based + * {@code grails-layout} plugin. Mutually exclusive with {@code sitemesh3}; + * selecting this feature replaces the default SiteMesh 3 decorator. + */ +@Singleton +public class GrailsLayout extends GspLayout { + + @Override + public String getName() { + return "grails-layout"; + } + + @Override + public String getTitle() { + return "GSP SiteMesh 2 Layouts"; + } + + @Override + public String getDescription() { + return "Adds support for legacy SiteMesh 2 based GSP layouts (grails-layout) instead of SiteMesh 3"; + } + + @Override + public void apply(GeneratorContext generatorContext) { + generatorContext.addDependency(Dependency.builder() + .groupId("org.apache.grails") + .artifactId("grails-layout") + .implementation()); + } +} diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GspLayout.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GspLayout.java new file mode 100644 index 00000000000..d818518645f --- /dev/null +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GspLayout.java @@ -0,0 +1,47 @@ +/* + * 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.forge.feature.view; + +import org.grails.forge.application.ApplicationType; +import org.grails.forge.feature.Category; +import org.grails.forge.feature.OneOfFeature; + +/** + * Common parent for the mutually exclusive GSP layout decorators: SiteMesh 3 + * ({@code grails-sitemesh3}) and the legacy SiteMesh 2 based {@code grails-layout}. + * Because they share the same {@link #getFeatureClass()}, only one of them may be + * selected for a given application (enforced by the one-of feature validator). + */ +public abstract class GspLayout implements OneOfFeature { + + @Override + public Class getFeatureClass() { + return GspLayout.class; + } + + @Override + public boolean supports(ApplicationType applicationType) { + return applicationType == ApplicationType.WEB || applicationType == ApplicationType.WEB_PLUGIN; + } + + @Override + public String getCategory() { + return Category.VIEW; + } +} diff --git a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GspLayoutSpec.groovy b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GspLayoutSpec.groovy new file mode 100644 index 00000000000..3ddadd80582 --- /dev/null +++ b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GspLayoutSpec.groovy @@ -0,0 +1,72 @@ +/* + * 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.forge.feature.view + +import org.grails.forge.ApplicationContextSpec +import org.grails.forge.BuildBuilder +import org.grails.forge.application.ApplicationType +import spock.lang.Unroll + +class GspLayoutSpec extends ApplicationContextSpec { + + @Unroll + void "web #applicationType apps use SiteMesh 3 (grails-sitemesh3) by default"() { + when: + final String build = new BuildBuilder(beanContext) + .features(["gsp"]) + .applicationType(applicationType) + .render() + + then: + build.contains('implementation "org.apache.grails:grails-sitemesh3"') + !build.contains('implementation "org.apache.grails:grails-layout"') + + and: "a SNAPSHOT build exposes the SiteMesh 3 snapshot repo so org.sitemesh:*-SNAPSHOT resolves" + build.contains("https://central.sonatype.com/repository/maven-snapshots") + build.contains("includeVersionByRegex('org[.]sitemesh.*'") + + where: + applicationType << [ApplicationType.WEB, ApplicationType.WEB_PLUGIN] + } + + void "selecting grails-layout replaces the default SiteMesh 3 decorator"() { + when: + final String build = new BuildBuilder(beanContext) + .features(["gsp", "grails-layout"]) + .applicationType(ApplicationType.WEB) + .render() + + then: + build.contains('implementation "org.apache.grails:grails-layout"') + !build.contains('implementation "org.apache.grails:grails-sitemesh3"') + } + + void "sitemesh3 and grails-layout cannot both be selected"() { + when: + new BuildBuilder(beanContext) + .features(["gsp", "sitemesh3", "grails-layout"]) + .applicationType(ApplicationType.WEB) + .render() + + then: + IllegalArgumentException e = thrown() + e.message.contains("There can only be one of the following features selected") + } +} From d561d7defa1eb04a73dca28956b48f4d51482974 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 3 Jun 2026 09:47:51 -0400 Subject: [PATCH 2/5] Undo revert --- grails-profiles/web/profile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-profiles/web/profile.yml b/grails-profiles/web/profile.yml index bc8d2f0d979..f02234e38f6 100644 --- a/grails-profiles/web/profile.yml +++ b/grails-profiles/web/profile.yml @@ -44,7 +44,7 @@ dependencies: - scope: implementation coords: "org.apache.grails:grails-url-mappings" - scope: implementation - coords: "org.apache.grails:grails-layout" + coords: "org.apache.grails:grails-sitemesh3" - scope: implementation coords: "org.apache.grails:grails-interceptors" - scope: implementation From a553b4724a2ff1f0ea527110cda8767091dbcc4b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 4 Jun 2026 11:24:36 -0700 Subject: [PATCH 3/5] Model the GSP layout choice as a one-of option to fix the forge UI glitch Address review feedback on the SiteMesh 3 default change: - Align the SiteMesh 3 feature title with SiteMesh 2 ("GSP SiteMesh 3 Layouts") and clean up GspLayoutSpec (single quotes, drop the short-lived SNAPSHOT-repo assertions). - Fix the UI glitch where replacing the default layout left both sitemesh3 and grails-layout selected. The two decorators are now driven by a single GspLayoutImpl option (SITEMESH3 default, GRAILS_LAYOUT) instead of a visible feature card, mirroring the servlet and reloading one-of groups. Both members are invisible DefaultFeatures that apply based on the selected option, so the default-features endpoint always resolves exactly one member. Threads the option through Options, FeatureFilter, ApplicationController and ContextFactory, exposes it via select-options (GspLayoutImplDTO / GspLayoutImplSelectOptions) for the UI dropdown, and adds a --gsp-layout CLI option. Updates BuildBuilder and GspLayoutSpec and adds SelectOptionsControllerSpec. --- .../forge/api/ApplicationController.java | 3 +- .../grails/forge/api/GspLayoutImplDTO.java | 100 ++++++++++++++++++ .../grails/forge/api/SelectOptionsDTO.java | 22 +++- .../options/GspLayoutImplSelectOptions.java | 42 ++++++++ .../SelectOptionsControllerSpec.groovy | 48 +++++++++ .../forge/cli/command/CreateCommand.java | 13 ++- .../cli/command/GspLayoutImplCandidates.java | 31 ++++++ .../cli/command/GspLayoutImplConverter.java | 41 +++++++ .../forge/application/ContextFactory.java | 10 +- .../forge/feature/sitemesh3/Sitemesh3.java | 12 +-- .../forge/feature/view/GrailsLayout.java | 15 ++- .../grails/forge/feature/view/GspLayout.java | 12 ++- .../grails/forge/options/FeatureFilter.java | 10 ++ .../grails/forge/options/GspLayoutImpl.java | 54 ++++++++++ .../org/grails/forge/options/Options.java | 31 +++++- .../org/grails/forge/BuildBuilder.groovy | 8 ++ .../forge/feature/view/GspLayoutSpec.groovy | 29 ++--- 17 files changed, 449 insertions(+), 32 deletions(-) create mode 100644 grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/GspLayoutImplDTO.java create mode 100644 grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/options/GspLayoutImplSelectOptions.java create mode 100644 grails-forge/grails-forge-api/src/test/groovy/org/grails/forge/api/options/SelectOptionsControllerSpec.groovy create mode 100644 grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/GspLayoutImplCandidates.java create mode 100644 grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/GspLayoutImplConverter.java create mode 100644 grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/GspLayoutImpl.java diff --git a/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/ApplicationController.java b/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/ApplicationController.java index c3182db1e4e..64a0319236b 100644 --- a/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/ApplicationController.java +++ b/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/ApplicationController.java @@ -235,6 +235,7 @@ protected Options getOptions(@Nullable FeatureFilter filter, RequestInfo request filter.getGorm() == null ? GormImpl.DEFAULT_OPTION : filter.getGorm(), filter.getServlet() == null ? ServletImpl.DEFAULT_OPTION : filter.getServlet(), filter.getJavaVersion() == null ? JdkVersion.DEFAULT_OPTION : filter.getJavaVersion(), - getOperatingSystem(requestInfo.getUserAgent())); + getOperatingSystem(requestInfo.getUserAgent())) + .withGspLayoutImpl(filter.getGspLayout() == null ? GspLayoutImpl.DEFAULT_OPTION : filter.getGspLayout()); } } diff --git a/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/GspLayoutImplDTO.java b/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/GspLayoutImplDTO.java new file mode 100644 index 00000000000..c25b70329c3 --- /dev/null +++ b/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/GspLayoutImplDTO.java @@ -0,0 +1,100 @@ +/* + * 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.forge.api; + +import io.micronaut.context.MessageSource; +import io.micronaut.core.annotation.Creator; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.naming.Described; +import io.micronaut.core.naming.Named; +import io.swagger.v3.oas.annotations.media.Schema; +import org.grails.forge.options.GspLayoutImpl; + +/** + * DTO objects for {@link GspLayoutImpl}. + * + * @since 8.0.0 + */ +@Schema(name = "GspLayoutImplInfo") +@Introspected +public class GspLayoutImplDTO extends Linkable implements Named, Described, Selectable { + + static final String MESSAGE_PREFIX = GrailsForgeConfiguration.PREFIX + ".gspLayoutImpl."; + + private final String name; + private final String description; + private final GspLayoutImpl value; + + /** + * @param gspLayoutImpl The {@link GspLayoutImpl} + */ + public GspLayoutImplDTO(GspLayoutImpl gspLayoutImpl) { + this.value = gspLayoutImpl; + this.name = gspLayoutImpl.getName(); + this.description = gspLayoutImpl.getName(); + } + + @Creator + @Internal + GspLayoutImplDTO(GspLayoutImpl gspLayoutImpl, + String name, + String description) { + this.value = gspLayoutImpl; + this.name = name; + this.description = description; + } + + @Internal + GspLayoutImplDTO(GspLayoutImpl gspLayoutImpl, + MessageSource messageSource, + MessageSource.MessageContext messageContext) { + this.value = gspLayoutImpl; + String name = gspLayoutImpl.getName(); + this.name = name; + this.description = messageSource.getMessage(MESSAGE_PREFIX + name + ".description", messageContext, name); + } + + @NonNull + @Override + @Schema(description = "A description of the GSP Layout Implementation") + public String getDescription() { + return description; + } + + @Override + @Schema(description = "The name of the GSP Layout Implementation") + @NonNull + public String getName() { + return name; + } + + @Override + @Schema(description = "The value of the GSP Layout Implementation for select options") + public GspLayoutImpl getValue() { + return value; + } + + @Override + @Schema(description = "The label of the GSP Layout Implementation for select options") + public String getLabel() { + return value.getLabel(); + } +} diff --git a/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/SelectOptionsDTO.java b/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/SelectOptionsDTO.java index f25ee04940f..b4345bf574a 100644 --- a/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/SelectOptionsDTO.java +++ b/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/SelectOptionsDTO.java @@ -52,6 +52,8 @@ public class SelectOptionsDTO { private ServletImplSelectOptions servlet; + private GspLayoutImplSelectOptions gspLayout; + SelectOptionsDTO() { } @@ -61,13 +63,15 @@ public SelectOptionsDTO(ApplicationTypeSelectOptions type, LanguageSelectOptions lang, DevelopmentReloadingSelectOptions reloading, GormImplSelectOptions gorm, - ServletImplSelectOptions servlet) { + ServletImplSelectOptions servlet, + GspLayoutImplSelectOptions gspLayout) { this.type = type; this.jdkVersion = jdkVersion; this.lang = lang; this.reloading = reloading; this.gorm = gorm; this.servlet = servlet; + this.gspLayout = gspLayout; } @Schema(description = "supported options for application type") @@ -100,6 +104,11 @@ public ServletImplSelectOptions getServlet() { return servlet; } + @Schema(description = "supported options for GSP Layout Implementation") + public GspLayoutImplSelectOptions getGspLayout() { + return gspLayout; + } + /** * Build the options * @@ -163,8 +172,17 @@ public static SelectOptionsDTO make(MessageSource messageSource, MessageSource.M new ServletImplDTO(ServletImpl.DEFAULT_OPTION, messageSource, messageContext) ); + List gspLayoutImpls = Arrays.stream(GspLayoutImpl.values()) + .map(it -> new GspLayoutImplDTO(it, messageSource, messageContext)) + .collect(Collectors.toList()); + + GspLayoutImplSelectOptions gspLayoutImplOpts = new GspLayoutImplSelectOptions( + gspLayoutImpls, + new GspLayoutImplDTO(GspLayoutImpl.DEFAULT_OPTION, messageSource, messageContext) + ); + - return new SelectOptionsDTO(applicationOpts, jdkVersionOpts, languageOpts, developmentReloadingOpts, gormImplOpts, servletImplOpts); + return new SelectOptionsDTO(applicationOpts, jdkVersionOpts, languageOpts, developmentReloadingOpts, gormImplOpts, servletImplOpts, gspLayoutImplOpts); } } diff --git a/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/options/GspLayoutImplSelectOptions.java b/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/options/GspLayoutImplSelectOptions.java new file mode 100644 index 00000000000..c7e76e431d5 --- /dev/null +++ b/grails-forge/grails-forge-api/src/main/java/org/grails/forge/api/options/GspLayoutImplSelectOptions.java @@ -0,0 +1,42 @@ +/* + * 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.forge.api.options; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.grails.forge.api.GspLayoutImplDTO; +import org.grails.forge.api.SelectOptionDTO; + +import java.util.List; + +@Schema(name = "GspLayoutImplSelectOptions") +public class GspLayoutImplSelectOptions extends SelectOptionDTO { + public GspLayoutImplSelectOptions(List options, GspLayoutImplDTO defaultOption) { + super(options, defaultOption); + } + + @Override + public List getOptions() { + return super.getOptions(); + } + + @Override + public GspLayoutImplDTO getDefaultOption() { + return super.getDefaultOption(); + } +} diff --git a/grails-forge/grails-forge-api/src/test/groovy/org/grails/forge/api/options/SelectOptionsControllerSpec.groovy b/grails-forge/grails-forge-api/src/test/groovy/org/grails/forge/api/options/SelectOptionsControllerSpec.groovy new file mode 100644 index 00000000000..349c156054f --- /dev/null +++ b/grails-forge/grails-forge-api/src/test/groovy/org/grails/forge/api/options/SelectOptionsControllerSpec.groovy @@ -0,0 +1,48 @@ +/* + * 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.forge.api.options + +import io.micronaut.http.HttpRequest +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.grails.forge.api.SelectOptionsDTO +import org.grails.forge.options.GspLayoutImpl +import spock.lang.Specification + +@MicronautTest +class SelectOptionsControllerSpec extends Specification { + + @Inject + @Client("/") + HttpClient httpClient + + void "select options expose the GSP layout implementations with SiteMesh 3 as the default"() { + when: + SelectOptionsDTO selectOptions = httpClient.toBlocking() + .retrieve(HttpRequest.GET('/select-options'), SelectOptionsDTO) + + then: + selectOptions.gspLayout + selectOptions.gspLayout.defaultOption.value == GspLayoutImpl.SITEMESH3 + selectOptions.gspLayout.options*.value as Set == [GspLayoutImpl.SITEMESH3, GspLayoutImpl.GRAILS_LAYOUT] as Set + } +} diff --git a/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/CreateCommand.java b/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/CreateCommand.java index aa478ae6749..0f1272ed53f 100644 --- a/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/CreateCommand.java +++ b/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/CreateCommand.java @@ -57,6 +57,10 @@ public abstract class CreateCommand extends BaseCommand implements Callable getAdditionalOptions() { public Integer call() throws Exception { if (listFeatures) { new ListFeatures(availableFeatures, - new Options(reloading, gormImpl, servletImpl, getJdkVersion(), getOperatingSystem()), + new Options(reloading, gormImpl, servletImpl, getJdkVersion(), getOperatingSystem()).withGspLayoutImpl(getGspLayoutImpl()), applicationType, getOperatingSystem(), contextFactory).output(this); @@ -122,11 +126,16 @@ public void generate(OutputHandler outputHandler) throws Exception { } public void generate(Project project, OutputHandler outputHandler) throws Exception { - Options options = new Options(reloading, gormImpl, servletImpl, getJdkVersion(), getOperatingSystem(), getAdditionalOptions()); + Options options = new Options(reloading, gormImpl, servletImpl, getJdkVersion(), getOperatingSystem(), getAdditionalOptions()) + .withGspLayoutImpl(getGspLayoutImpl()); projectGenerator.generate(applicationType, project, options, getOperatingSystem(), getSelectedFeatures(), outputHandler, this); } + private GspLayoutImpl getGspLayoutImpl() { + return gspLayoutImpl == null ? GspLayoutImpl.DEFAULT_OPTION : gspLayoutImpl; + } + private JdkVersion getJdkVersion() { if (javaVersion == null) { return JdkVersion.DEFAULT_OPTION; diff --git a/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/GspLayoutImplCandidates.java b/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/GspLayoutImplCandidates.java new file mode 100644 index 00000000000..f8bd3d75075 --- /dev/null +++ b/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/GspLayoutImplCandidates.java @@ -0,0 +1,31 @@ +/* + * 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.forge.cli.command; + +import org.grails.forge.options.GspLayoutImpl; + +import java.util.ArrayList; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class GspLayoutImplCandidates extends ArrayList { + public GspLayoutImplCandidates() { + super(Stream.of(GspLayoutImpl.values()).map(GspLayoutImpl::getName).collect(Collectors.toList())); + } +} diff --git a/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/GspLayoutImplConverter.java b/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/GspLayoutImplConverter.java new file mode 100644 index 00000000000..8e3efad2727 --- /dev/null +++ b/grails-forge/grails-forge-cli/src/main/java/org/grails/forge/cli/command/GspLayoutImplConverter.java @@ -0,0 +1,41 @@ +/* + * 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.forge.cli.command; + +import io.micronaut.core.annotation.Introspected; +import org.grails.forge.options.GspLayoutImpl; +import picocli.CommandLine; + +@Introspected +public class GspLayoutImplConverter implements CommandLine.ITypeConverter { + + @Override + public GspLayoutImpl convert(String value) throws Exception { + if (value == null) { + return GspLayoutImpl.DEFAULT_OPTION; + } else { + for (GspLayoutImpl impl : GspLayoutImpl.values()) { + if (value.equalsIgnoreCase(impl.getName()) || value.equalsIgnoreCase(impl.name())) { + return impl; + } + } + } + throw new CommandLine.TypeConversionException("Invalid GSP layout implementation selection: " + value); + } +} diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/application/ContextFactory.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/application/ContextFactory.java index f499008902b..27fc48ca266 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/application/ContextFactory.java +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/application/ContextFactory.java @@ -65,7 +65,8 @@ public FeatureContext createFeatureContext(AvailableFeatures availableFeatures, Options newOptions = options .withDevelopmentReloading(determineDevelopmentReloading(options.getDevelopmentReloading())) .withGormImpl(determineGormImpl(options.getGormImpl())) - .withServletImpl(determineServletImpl(options.getServletImpl())); + .withServletImpl(determineServletImpl(options.getServletImpl())) + .withGspLayoutImpl(determineGspLayoutImpl(options.getGspLayoutImpl())); availableFeatures.getAllFeatures() .filter(f -> f instanceof DefaultFeature) @@ -120,6 +121,13 @@ GormImpl determineGormImpl(GormImpl gormImpl) { return gormImpl; } + GspLayoutImpl determineGspLayoutImpl(GspLayoutImpl gspLayoutImpl) { + if (gspLayoutImpl == null) { + gspLayoutImpl = GspLayoutImpl.DEFAULT_OPTION; + } + return gspLayoutImpl; + } + ServletImpl determineServletImpl(ServletImpl servletImpl) { if (servletImpl == null) { servletImpl = ServletImpl.DEFAULT_OPTION; diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/sitemesh3/Sitemesh3.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/sitemesh3/Sitemesh3.java index 7d5fcddb2fd..ab5301439ed 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/sitemesh3/Sitemesh3.java +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/sitemesh3/Sitemesh3.java @@ -22,20 +22,20 @@ import org.grails.forge.application.ApplicationType; import org.grails.forge.application.generator.GeneratorContext; import org.grails.forge.build.dependencies.Dependency; -import org.grails.forge.feature.DefaultFeature; import org.grails.forge.feature.Feature; import org.grails.forge.feature.view.GspLayout; +import org.grails.forge.options.GspLayoutImpl; import org.grails.forge.options.Options; import java.util.Set; /** * Default GSP layout decorator, backed by SiteMesh 3 ({@code grails-sitemesh3}). - * Applied automatically to web applications unless another {@link GspLayout} - * (e.g. {@code grails-layout}) is explicitly selected. + * Applied automatically to web applications unless the {@link GspLayoutImpl} + * option selects the legacy SiteMesh 2 based {@code grails-layout} decorator. */ @Singleton -public class Sitemesh3 extends GspLayout implements DefaultFeature { +public class Sitemesh3 extends GspLayout { @Override public String getName() { @@ -44,7 +44,7 @@ public String getName() { @Override public String getTitle() { - return "SiteMesh 3"; + return "GSP SiteMesh 3 Layouts"; } @Override @@ -55,7 +55,7 @@ public String getDescription() { @Override public boolean shouldApply(ApplicationType applicationType, Options options, Set selectedFeatures) { return supports(applicationType) && - selectedFeatures.stream().noneMatch(GspLayout.class::isInstance); + options.getGspLayoutImpl() == GspLayoutImpl.SITEMESH3; } @Override diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsLayout.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsLayout.java index 5bd2b958b9c..52ab66af69d 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsLayout.java +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsLayout.java @@ -19,13 +19,20 @@ package org.grails.forge.feature.view; import jakarta.inject.Singleton; +import org.grails.forge.application.ApplicationType; import org.grails.forge.application.generator.GeneratorContext; import org.grails.forge.build.dependencies.Dependency; +import org.grails.forge.feature.Feature; +import org.grails.forge.options.GspLayoutImpl; +import org.grails.forge.options.Options; + +import java.util.Set; /** * Opt-in GSP layout decorator backed by the legacy SiteMesh 2 based * {@code grails-layout} plugin. Mutually exclusive with {@code sitemesh3}; - * selecting this feature replaces the default SiteMesh 3 decorator. + * applied when the {@link GspLayoutImpl} option selects {@code grails-layout} + * instead of the default SiteMesh 3 decorator. */ @Singleton public class GrailsLayout extends GspLayout { @@ -45,6 +52,12 @@ public String getDescription() { return "Adds support for legacy SiteMesh 2 based GSP layouts (grails-layout) instead of SiteMesh 3"; } + @Override + public boolean shouldApply(ApplicationType applicationType, Options options, Set selectedFeatures) { + return supports(applicationType) && + options.getGspLayoutImpl() == GspLayoutImpl.GRAILS_LAYOUT; + } + @Override public void apply(GeneratorContext generatorContext) { generatorContext.addDependency(Dependency.builder() diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GspLayout.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GspLayout.java index d818518645f..1f45ac85511 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GspLayout.java +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GspLayout.java @@ -20,6 +20,7 @@ import org.grails.forge.application.ApplicationType; import org.grails.forge.feature.Category; +import org.grails.forge.feature.DefaultFeature; import org.grails.forge.feature.OneOfFeature; /** @@ -27,8 +28,12 @@ * ({@code grails-sitemesh3}) and the legacy SiteMesh 2 based {@code grails-layout}. * Because they share the same {@link #getFeatureClass()}, only one of them may be * selected for a given application (enforced by the one-of feature validator). + * + *

The choice is driven by the {@link org.grails.forge.options.GspLayoutImpl} option + * rather than by selecting a feature, so the members are not visible as standalone + * features. Each member applies based on the selected option (see {@code shouldApply}). */ -public abstract class GspLayout implements OneOfFeature { +public abstract class GspLayout implements OneOfFeature, DefaultFeature { @Override public Class getFeatureClass() { @@ -44,4 +49,9 @@ public boolean supports(ApplicationType applicationType) { public String getCategory() { return Category.VIEW; } + + @Override + public boolean isVisible() { + return false; + } } diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/FeatureFilter.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/FeatureFilter.java index a0cc294b4e5..d5191209f6c 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/FeatureFilter.java +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/FeatureFilter.java @@ -29,6 +29,8 @@ public class FeatureFilter { private GormImpl gorm; @JsonProperty("servlet") private ServletImpl servlet; + @JsonProperty("gspLayout") + private GspLayoutImpl gspLayout; @JsonProperty("javaVersion") private JdkVersion javaVersion; @@ -56,6 +58,14 @@ public void setServlet(ServletImpl servlet) { this.servlet = servlet; } + public GspLayoutImpl getGspLayout() { + return gspLayout; + } + + public void setGspLayout(GspLayoutImpl gspLayout) { + this.gspLayout = gspLayout; + } + public JdkVersion getJavaVersion() { return javaVersion; } diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/GspLayoutImpl.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/GspLayoutImpl.java new file mode 100644 index 00000000000..667e0692f42 --- /dev/null +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/GspLayoutImpl.java @@ -0,0 +1,54 @@ +/* + * 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.forge.options; + +import io.micronaut.core.annotation.NonNull; + +/** + * The GSP layout decorator implementation. SiteMesh 3 ({@code grails-sitemesh3}) + * is the default; the legacy SiteMesh 2 based {@code grails-layout} plugin is the + * opt-in alternative. The two are mutually exclusive, so the choice is modelled as + * a single option rather than competing features. + * + * @since 8.0.0 + */ +public enum GspLayoutImpl { + + SITEMESH3("sitemesh3", "SiteMesh 3"), + GRAILS_LAYOUT("grails-layout", "SiteMesh 2"); + + public static final GspLayoutImpl DEFAULT_OPTION = SITEMESH3; + private final String featureName; + private final String label; + + GspLayoutImpl(String featureName, String label) { + this.featureName = featureName; + this.label = label; + } + + @NonNull + public String getName() { + return featureName; + } + + @NonNull + public String getLabel() { + return label; + } +} diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/Options.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/Options.java index b3ab6a01bb3..c78c4c8b0bb 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/Options.java +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/options/Options.java @@ -34,6 +34,7 @@ public class Options implements ConvertibleValues { private final BuildTool buildTool; private final GormImpl gormImpl; private final ServletImpl servletImpl; + private final GspLayoutImpl gspLayoutImpl; private final JdkVersion javaVersion; private final ConvertibleValuesMap additionalOptions; @@ -44,10 +45,22 @@ public Options(DevelopmentReloading reloading, OperatingSystem operatingSystem, Map additionalOptions) { + this(reloading, gormImpl, servletImpl, GspLayoutImpl.DEFAULT_OPTION, javaVersion, operatingSystem, additionalOptions); + } + + public Options(DevelopmentReloading reloading, + GormImpl gormImpl, + ServletImpl servletImpl, + GspLayoutImpl gspLayoutImpl, + JdkVersion javaVersion, + OperatingSystem operatingSystem, + Map additionalOptions) { + this.reloading = reloading; this.buildTool = BuildTool.DEFAULT_OPTION; this.gormImpl = gormImpl; this.servletImpl = servletImpl; + this.gspLayoutImpl = gspLayoutImpl; this.javaVersion = javaVersion; this.operatingSystem = operatingSystem; this.additionalOptions = new ConvertibleValuesMap<>(additionalOptions); @@ -117,6 +130,10 @@ public ServletImpl getServletImpl() { return servletImpl; } + public GspLayoutImpl getGspLayoutImpl() { + return gspLayoutImpl; + } + @Override public Set names() { return additionalOptions.names(); @@ -137,22 +154,26 @@ public JdkVersion getJavaVersion() { } public Options withOperatingSystem(OperatingSystem operatingSystem) { - return new Options(reloading, gormImpl, servletImpl, javaVersion, operatingSystem, additionalOptions.asMap()); + return new Options(reloading, gormImpl, servletImpl, gspLayoutImpl, javaVersion, operatingSystem, additionalOptions.asMap()); } public Options withDevelopmentReloading(DevelopmentReloading reloading) { - return new Options(reloading, gormImpl, servletImpl, javaVersion, operatingSystem, additionalOptions.asMap()); + return new Options(reloading, gormImpl, servletImpl, gspLayoutImpl, javaVersion, operatingSystem, additionalOptions.asMap()); } public Options withGormImpl(GormImpl gormImpl) { - return new Options(reloading, gormImpl, servletImpl, javaVersion, operatingSystem, additionalOptions.asMap()); + return new Options(reloading, gormImpl, servletImpl, gspLayoutImpl, javaVersion, operatingSystem, additionalOptions.asMap()); } public Options withServletImpl(ServletImpl servletImpl) { - return new Options(reloading, gormImpl, servletImpl, javaVersion, operatingSystem, additionalOptions.asMap()); + return new Options(reloading, gormImpl, servletImpl, gspLayoutImpl, javaVersion, operatingSystem, additionalOptions.asMap()); + } + + public Options withGspLayoutImpl(GspLayoutImpl gspLayoutImpl) { + return new Options(reloading, gormImpl, servletImpl, gspLayoutImpl, javaVersion, operatingSystem, additionalOptions.asMap()); } public Options withJavaVersion(JdkVersion javaVersion) { - return new Options(reloading, gormImpl, servletImpl, javaVersion, operatingSystem, additionalOptions.asMap()); + return new Options(reloading, gormImpl, servletImpl, gspLayoutImpl, javaVersion, operatingSystem, additionalOptions.asMap()); } } diff --git a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/BuildBuilder.groovy b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/BuildBuilder.groovy index 3a2cfa91fd5..a7838228a32 100644 --- a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/BuildBuilder.groovy +++ b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/BuildBuilder.groovy @@ -48,6 +48,7 @@ class BuildBuilder implements ProjectFixture, ContextFixture { private JdkVersion jdkVersion private GormImpl gormImpl private ServletImpl servletImpl + private GspLayoutImpl gspLayoutImpl private OperatingSystem operatingSystem private Project project private ApplicationContext ctx @@ -57,6 +58,7 @@ class BuildBuilder implements ProjectFixture, ContextFixture { this.ctx = ctx this.gormImpl = GormImpl.DEFAULT_OPTION this.servletImpl = ServletImpl.DEFAULT_OPTION + this.gspLayoutImpl = GspLayoutImpl.DEFAULT_OPTION this.operatingSystem = OperatingSystem.DEFAULT } @@ -98,6 +100,11 @@ class BuildBuilder implements ProjectFixture, ContextFixture { this } + BuildBuilder gspLayoutImpl(GspLayoutImpl gspLayoutImpl) { + this.gspLayoutImpl = gspLayoutImpl + this + } + BuildBuilder project(Project project) { this.project = project this @@ -111,6 +118,7 @@ class BuildBuilder implements ProjectFixture, ContextFixture { JdkVersion jdkVersion = this.jdkVersion ?: JdkVersion.DEFAULT_OPTION final Options options = new Options(reloading, gormImpl, servletImpl, jdkVersion, operatingSystem) + .withGspLayoutImpl(gspLayoutImpl ?: GspLayoutImpl.DEFAULT_OPTION) Features features = getFeatures(featureNames, options, type) GradleBuild build = gradleBuild(options, features, project, type) diff --git a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GspLayoutSpec.groovy b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GspLayoutSpec.groovy index 3ddadd80582..b947f56ddd2 100644 --- a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GspLayoutSpec.groovy +++ b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GspLayoutSpec.groovy @@ -22,6 +22,7 @@ package org.grails.forge.feature.view import org.grails.forge.ApplicationContextSpec import org.grails.forge.BuildBuilder import org.grails.forge.application.ApplicationType +import org.grails.forge.options.GspLayoutImpl import spock.lang.Unroll class GspLayoutSpec extends ApplicationContextSpec { @@ -30,7 +31,7 @@ class GspLayoutSpec extends ApplicationContextSpec { void "web #applicationType apps use SiteMesh 3 (grails-sitemesh3) by default"() { when: final String build = new BuildBuilder(beanContext) - .features(["gsp"]) + .features(['gsp']) .applicationType(applicationType) .render() @@ -38,35 +39,37 @@ class GspLayoutSpec extends ApplicationContextSpec { build.contains('implementation "org.apache.grails:grails-sitemesh3"') !build.contains('implementation "org.apache.grails:grails-layout"') - and: "a SNAPSHOT build exposes the SiteMesh 3 snapshot repo so org.sitemesh:*-SNAPSHOT resolves" - build.contains("https://central.sonatype.com/repository/maven-snapshots") - build.contains("includeVersionByRegex('org[.]sitemesh.*'") - where: applicationType << [ApplicationType.WEB, ApplicationType.WEB_PLUGIN] } - void "selecting grails-layout replaces the default SiteMesh 3 decorator"() { + @Unroll + void "the grails-layout option replaces the default SiteMesh 3 decorator for #applicationType"() { when: final String build = new BuildBuilder(beanContext) - .features(["gsp", "grails-layout"]) - .applicationType(ApplicationType.WEB) + .features(['gsp']) + .gspLayoutImpl(GspLayoutImpl.GRAILS_LAYOUT) + .applicationType(applicationType) .render() then: build.contains('implementation "org.apache.grails:grails-layout"') !build.contains('implementation "org.apache.grails:grails-sitemesh3"') + + where: + applicationType << [ApplicationType.WEB, ApplicationType.WEB_PLUGIN] } - void "sitemesh3 and grails-layout cannot both be selected"() { + void "the sitemesh3 option keeps the default SiteMesh 3 decorator"() { when: - new BuildBuilder(beanContext) - .features(["gsp", "sitemesh3", "grails-layout"]) + final String build = new BuildBuilder(beanContext) + .features(['gsp']) + .gspLayoutImpl(GspLayoutImpl.SITEMESH3) .applicationType(ApplicationType.WEB) .render() then: - IllegalArgumentException e = thrown() - e.message.contains("There can only be one of the following features selected") + build.contains('implementation "org.apache.grails:grails-sitemesh3"') + !build.contains('implementation "org.apache.grails:grails-layout"') } } From 021059d03e411e7e19cc6465ec1fdb0505db607b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 13:12:20 -0700 Subject: [PATCH 4/5] Run CI test apps against SiteMesh 3 by default The test apps already gate the layout dependency on the SITEMESH3_TESTING_ENABLED environment variable, but no CI lane set it, so the suite only ever exercised the legacy grails-layout. Set the flag at the workflow level in the CI and Groovy joint builds so the example apps and grails-test-suite-uber resolve grails-sitemesh3 by default. No build-script changes needed: the flag stays as-is, the SiteMesh 2 anchors keep their grails-layout coverage, and developers can still drop the flag to run against SiteMesh 2 locally. --- .github/workflows/gradle.yml | 5 +++++ .github/workflows/groovy-joint-workflow.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index b5e85454e5b..d380c490259 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -24,6 +24,11 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# Exercise SiteMesh 3 by default in the test apps. The example/suite builds read this +# flag to choose grails-sitemesh3 over the legacy grails-layout; unset it (or set it to +# anything other than 'true') to fall back to SiteMesh 2. +env: + SITEMESH3_TESTING_ENABLED: 'true' jobs: validateDependencies: name: 'Validate Dependency Versions' diff --git a/.github/workflows/groovy-joint-workflow.yml b/.github/workflows/groovy-joint-workflow.yml index 1b4f0cc430a..18e10766488 100644 --- a/.github/workflows/groovy-joint-workflow.yml +++ b/.github/workflows/groovy-joint-workflow.yml @@ -28,6 +28,11 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read +# Exercise SiteMesh 3 by default in the test apps. The example/suite builds read this +# flag to choose grails-sitemesh3 over the legacy grails-layout; unset it (or set it to +# anything other than 'true') to fall back to SiteMesh 2. +env: + SITEMESH3_TESTING_ENABLED: 'true' jobs: build_groovy: if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }} From 920c12c9019d879a11f6f42e2066ea9aec5cf72e Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 22:06:15 -0700 Subject: [PATCH 5/5] Fix empty SiteMesh 3 output for undecorated GSP responses In the filterless SiteMesh 3 pipeline the GSP capture taglib writes the full / markup to the response buffer and also captures the head/body into a Sitemesh3CapturedPage. When no decorator is selected (e.g. a view with no and no matching convention or default layout), SiteMeshView writes content.getData() back. The captured page had neither renderedContent nor pageBuffer set, so getData() reconstructed only from (empty) properties and emitted an empty . Attach the original response buffer as the captured page's rendered content in the no-merge branch so the original page is written back when nothing decorates it. Decorated (meta-layout) pages are unaffected: they take the decorate branch, which reads the head/body child properties. Verified against grails-test-examples/app1 integration tests under SITEMESH3_TESTING_ENABLED=true: ConfigTestControllerSpec, ControllerIncludesSpec, ControllerFromPluginSpec and ConditionalOnPropertyFromPluginYmlSpec now pass, with no regression to meta-layout pages (BookFunctionalSpec). --- .../plugins/sitemesh3/CaptureAwareContentProcessor.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 9dc2fada835..9ab8864c509 100644 --- 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 @@ -80,6 +80,14 @@ public Content build(CharBuffer data, SiteMeshContext context) throws IOExceptio } if (captured != null && captured.isUsed()) { + // The capture taglib also writes the full / markup to + // the response buffer, so `data` is the complete original page. + // Attach it as the page's rendered data so that, when no decorator + // is selected, SiteMeshView writes the original page back instead + // of the (empty) reconstructed-from-properties data chunk. Head/body + // child properties are still materialized for any decorator that IS + // selected, so meta-layout pages are unaffected. + captured.setRenderedContent(data); return captured; } return fallback.build(data, context);