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]') }} 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 73fd1dc66a3..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,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.Feature; +import org.grails.forge.feature.view.GspLayout; +import org.grails.forge.options.GspLayoutImpl; +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 the {@link GspLayoutImpl} + * option selects the legacy SiteMesh 2 based {@code grails-layout} decorator. + */ +@Singleton +public class Sitemesh3 extends GspLayout { @Override public String getName() { @@ -38,12 +44,18 @@ public String getName() { @Override public String getTitle() { - return "Sitemesh 3"; + return "GSP SiteMesh 3 Layouts"; } @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) && + options.getGspLayoutImpl() == GspLayoutImpl.SITEMESH3; } @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..52ab66af69d --- /dev/null +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GrailsLayout.java @@ -0,0 +1,68 @@ +/* + * 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.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}; + * applied when the {@link GspLayoutImpl} option selects {@code grails-layout} + * instead of 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 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() + .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..1f45ac85511 --- /dev/null +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/view/GspLayout.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.forge.feature.view; + +import org.grails.forge.application.ApplicationType; +import org.grails.forge.feature.Category; +import org.grails.forge.feature.DefaultFeature; +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). + * + *

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, DefaultFeature { + + @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; + } + + @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 new file mode 100644 index 00000000000..b947f56ddd2 --- /dev/null +++ b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GspLayoutSpec.groovy @@ -0,0 +1,75 @@ +/* + * 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 org.grails.forge.options.GspLayoutImpl +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"') + + where: + applicationType << [ApplicationType.WEB, ApplicationType.WEB_PLUGIN] + } + + @Unroll + void "the grails-layout option replaces the default SiteMesh 3 decorator for #applicationType"() { + when: + final String build = new BuildBuilder(beanContext) + .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 "the sitemesh3 option keeps the default SiteMesh 3 decorator"() { + when: + final String build = new BuildBuilder(beanContext) + .features(['gsp']) + .gspLayoutImpl(GspLayoutImpl.SITEMESH3) + .applicationType(ApplicationType.WEB) + .render() + + then: + build.contains('implementation "org.apache.grails:grails-sitemesh3"') + !build.contains('implementation "org.apache.grails:grails-layout"') + } +} 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); 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