From d62ae0a41a560b217644efb298239ef480177e59 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 8 Jul 2026 10:26:42 -0600 Subject: [PATCH 1/4] Add available-locale discovery and a language selector to i18n Introduce AvailableLocaleResolver in grails-i18n, which discovers the locales an application is actually translated into by scanning the classpath for messages_*.properties bundles (plus the configurable default locale, grails.i18n.default.locale). Unlike Locale.getAvailableLocales(), this returns only real translations, so it can drive a language selector. The resolver is registered as a bean by I18nAutoConfiguration and published to the servlet context by I18nGrailsPlugin, keeping consumers decoupled from the i18n module. Add an available="true" mode to g:localeSelect that lists only those locales rather than every JVM locale, and add a Bootstrap language dropdown to the default create-app layout -- both the grails-forge template and the classic web profile skeleton -- which switches locale via the existing ?lang= interceptor. Includes unit tests for the resolver, the tag, and the generated layout, plus reference and guide documentation. --- .../src/en/guide/i18n/changingLocales.adoc | 11 ++ .../src/en/ref/Tags - GSP/localeSelect.adoc | 4 + .../src/main/resources/gsp/main.gsp | 18 +++ .../forge/feature/view/GrailsGspSpec.groovy | 13 ++ .../plugins/web/taglib/FormTagLib.groovy | 13 +- .../taglib/LocaleSelectAvailableSpec.groovy | 85 ++++++++++++ .../plugins/i18n/AvailableLocaleResolver.java | 130 ++++++++++++++++++ .../plugins/i18n/I18nAutoConfiguration.java | 19 +++ .../plugins/i18n/I18nGrailsPlugin.groovy | 31 +++++ .../i18n/AvailableLocaleResolverSpec.groovy | 89 ++++++++++++ .../src/test/resources/messages.properties | 1 + .../src/test/resources/messages_es.properties | 1 + .../src/test/resources/messages_fr.properties | 1 + .../test/resources/messages_pt_BR.properties | 1 + .../grails-app/views/layouts/main.gsp | 18 +++ 15 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LocaleSelectAvailableSpec.groovy create mode 100644 grails-i18n/src/main/groovy/org/grails/plugins/i18n/AvailableLocaleResolver.java create mode 100644 grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy create mode 100644 grails-i18n/src/test/resources/messages.properties create mode 100644 grails-i18n/src/test/resources/messages_es.properties create mode 100644 grails-i18n/src/test/resources/messages_fr.properties create mode 100644 grails-i18n/src/test/resources/messages_pt_BR.properties diff --git a/grails-doc/src/en/guide/i18n/changingLocales.adoc b/grails-doc/src/en/guide/i18n/changingLocales.adoc index 5aaaa8ec92b..5a251301cc6 100644 --- a/grails-doc/src/en/guide/i18n/changingLocales.adoc +++ b/grails-doc/src/en/guide/i18n/changingLocales.adoc @@ -26,6 +26,17 @@ By default, the user locale is detected from the incoming `Accept-Language` head Grails will automatically switch the user's locale and subsequent requests will use the switched locale. +To offer a language selector, the i18n plugin discovers the locales your application is actually translated into by scanning the classpath for `messages_*.properties` bundles (plus the default locale, configurable via `grails.i18n.default.locale`). This list is exposed as the `AvailableLocaleResolver` bean and published to the servlet context under the `availableLocales` attribute. The `` tag renders a `` limited to those locales, and you can iterate the list directly in a view to build your own switcher: +By default every `*.properties` bundle on the classpath is considered, so locales contributed by plugins — whose bundles are namespaced (for example `spring-security-core_fr.properties`) — are listed alongside your application's own. Candidate suffixes are validated against `Locale.getISOLanguages()`, so non-i18n properties files (such as `application.properties`) are ignored. To restrict discovery to your application's own `messages_*.properties` bundles, set `grails.i18n.availableLocales.includePlugins=false`: + +[source,yaml] +.grails-app/conf/application.yml +---- +grails: + i18n: + availableLocales: + includePlugins: false # list only the application's own translations +---- + + [source,xml] ---- diff --git a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/AvailableLocaleResolver.java b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/AvailableLocaleResolver.java index 0194537514b..1f322f4b201 100644 --- a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/AvailableLocaleResolver.java +++ b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/AvailableLocaleResolver.java @@ -20,8 +20,10 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -36,7 +38,7 @@ /** * Discovers the locales an application is actually translated into by scanning the - * classpath for {@code messages_*.properties} resource bundles. + * classpath for {@code _.properties} resource bundles. * *

Unlike {@link java.util.Locale#getAvailableLocales()} (which returns every locale * the JVM knows about), this returns only the locales that have a matching message @@ -44,32 +46,56 @@ * selector. The result is sorted by each locale's display name in its own language and * cached; {@link #clearCache()} forces a re-scan (used when bundles change in development). * + *

By default only the application's own {@code messages_*.properties} bundles are + * scanned. When {@code includePluginBundles} is enabled, every {@code *.properties} + * bundle on the classpath is considered, so locales contributed by plugins — whose + * bundles are namespaced (e.g. {@code spring-security-core_fr.properties}) — are + * included too. Candidate suffixes are validated against {@link Locale#getISOLanguages()} + * so non-i18n properties files (e.g. {@code application.properties}) are ignored. See + * {@code grails.i18n.availableLocales.includePlugins}. + * * @since 8.0.0 */ public class AvailableLocaleResolver { private static final Logger log = LoggerFactory.getLogger(AvailableLocaleResolver.class); - private static final String MESSAGES_PREFIX = "messages_"; + /** The base name of an application's own message bundles ({@code messages.properties}). */ + public static final String DEFAULT_BASE_NAME = "messages"; private static final String PROPERTIES_SUFFIX = ".properties"; - private static final String LOCATION_PATTERN = "classpath*:" + MESSAGES_PREFIX + "*" + PROPERTIES_SUFFIX; + private static final Set ISO_LANGUAGES = new HashSet<>(Arrays.asList(Locale.getISOLanguages())); private final ResourcePatternResolver resourcePatternResolver; private final Locale defaultLocale; + private final boolean includePluginBundles; + private volatile List cachedLocales; /** + * Scans only the application's own {@code messages_*.properties} bundles. + * * @param classLoader the class loader whose classpath is scanned for message bundles * @param defaultLocale the locale of the base {@code messages.properties} bundle, * always included in the result (may be {@code null} to include none) */ public AvailableLocaleResolver(ClassLoader classLoader, Locale defaultLocale) { + this(classLoader, defaultLocale, false); + } + + /** + * @param classLoader the class loader whose classpath is scanned for message bundles + * @param defaultLocale the locale of the base bundle, always included (may be {@code null}) + * @param includePluginBundles whether to also consider plugin-contributed bundles (every + * {@code *.properties} on the classpath) rather than only the application's {@code messages} + */ + public AvailableLocaleResolver(ClassLoader classLoader, Locale defaultLocale, boolean includePluginBundles) { this.resourcePatternResolver = new PathMatchingResourcePatternResolver(classLoader); this.defaultLocale = defaultLocale; + this.includePluginBundles = includePluginBundles; } /** @@ -102,29 +128,46 @@ private List computeAvailableLocales() { if (this.defaultLocale != null && !this.defaultLocale.getLanguage().isEmpty()) { locales.add(this.defaultLocale); } + String pattern = "classpath*:" + DEFAULT_BASE_NAME + "_*" + PROPERTIES_SUFFIX; + if (this.includePluginBundles) { + pattern = "classpath*:*" + PROPERTIES_SUFFIX; + } try { - for (Resource resource : this.resourcePatternResolver.getResources(LOCATION_PATTERN)) { - String filename = resource.getFilename(); - if (filename == null || !filename.startsWith(MESSAGES_PREFIX) || !filename.endsWith(PROPERTIES_SUFFIX)) { - continue; - } - String code = filename.substring(MESSAGES_PREFIX.length(), filename.length() - PROPERTIES_SUFFIX.length()); - if (code.isEmpty()) { - continue; - } - Locale locale = Locale.forLanguageTag(code.replace('_', '-')); - if (!locale.getLanguage().isEmpty()) { + for (Resource resource : this.resourcePatternResolver.getResources(pattern)) { + Locale locale = localeFromBundleFilename(resource.getFilename()); + if (locale != null) { locales.add(locale); } } } catch (IOException ex) { - log.warn("Unable to resolve available locales from '{}*{}' message bundles: {}", - MESSAGES_PREFIX, PROPERTIES_SUFFIX, ex.getMessage()); + log.warn("Unable to resolve available locales from message bundles: {}", ex.getMessage()); } List sorted = new ArrayList<>(locales); sorted.sort(Comparator.comparing((Locale locale) -> locale.getDisplayName(locale))); return Collections.unmodifiableList(sorted); } + /** + * Extracts the locale a bundle filename encodes, or {@code null} if it is not a locale-specific + * message bundle. The base name ends at the first underscore (matching Grails' own message-source + * convention, where plugin base names use hyphens), and the suffix must be a recognised language. + */ + private static Locale localeFromBundleFilename(String filename) { + if (filename == null || !filename.endsWith(PROPERTIES_SUFFIX)) { + return null; + } + String base = filename.substring(0, filename.length() - PROPERTIES_SUFFIX.length()); + int underscore = base.indexOf('_'); + if (underscore < 0) { + return null; + } + String code = base.substring(underscore + 1); + if (code.isEmpty()) { + return null; + } + Locale locale = Locale.forLanguageTag(code.replace('_', '-')); + return ISO_LANGUAGES.contains(locale.getLanguage()) ? locale : null; + } + } diff --git a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java index 0517fc2f367..999fb40350b 100644 --- a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java +++ b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java @@ -90,19 +90,28 @@ public MessageSource messageSource(GrailsApplication grailsApplication, GrailsPl } /** - * Discovers the locales the application is translated into (from {@code messages_*.properties} + * Discovers the locales the application is translated into (from {@code _.properties} * bundles on the classpath) so that a language selector can list only real translations rather * than every JVM locale. Published to the servlet context by {@link I18nGrailsPlugin} and * consumed by the {@code g:localeSelect available="true"} tag. * + *

By default every {@code *.properties} bundle on the classpath is considered, so locales + * contributed by plugins — whose bundles are namespaced (e.g. + * {@code spring-security-core_*.properties}) — are included alongside the application's own. + * Set {@code grails.i18n.availableLocales.includePlugins=false} to restrict discovery to the + * application's own {@code messages_*.properties} bundles. + * * @param defaultLocale the base {@code messages.properties} locale, always included * ({@code grails.i18n.default.locale}, defaults to {@code en}) + * @param includePlugins whether to also scan plugin-contributed message bundles + * ({@code grails.i18n.availableLocales.includePlugins}, defaults to {@code true}) */ @Bean @ConditionalOnMissingBean(AvailableLocaleResolver.class) public AvailableLocaleResolver availableLocaleResolver(GrailsApplication grailsApplication, - @Value("${grails.i18n.default.locale:en}") String defaultLocale) { + @Value("${grails.i18n.default.locale:en}") String defaultLocale, + @Value("${grails.i18n.availableLocales.includePlugins:true}") boolean includePlugins) { return new AvailableLocaleResolver(grailsApplication.getClassLoader(), - Locale.forLanguageTag(defaultLocale.replace('_', '-'))); + Locale.forLanguageTag(defaultLocale.replace('_', '-')), includePlugins); } } diff --git a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy index 1fef5ccf182..c86a4c9c5f6 100644 --- a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy +++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy @@ -41,6 +41,32 @@ class AvailableLocaleResolverSpec extends Specification { locales.contains(Locale.forLanguageTag('pt-BR')) } + void 'plugin-namespaced bundles are discovered only when plugin bundles are included'() { + given: 'a plugin ships spring-security-core_zu.properties (a locale no messages bundle has)' + Locale zulu = Locale.forLanguageTag('zu') + + expect: 'a host-only scan does not see the plugin-namespaced locale' + !new AvailableLocaleResolver(getClass().classLoader, Locale.forLanguageTag('en'), false) + .availableLocales.contains(zulu) + + when: 'plugin bundles are included' + List locales = new AvailableLocaleResolver(getClass().classLoader, + Locale.forLanguageTag('en'), true).availableLocales + + then: 'the plugin locale is discovered, alongside the host messages locales' + locales.contains(zulu) + locales.contains(Locale.forLanguageTag('es')) + } + + void 'non-i18n properties files on the classpath are ignored'() { + given: + List isoLanguages = Locale.getISOLanguages() as List + + expect: 'application.properties / logback.properties etc. never contribute a bogus locale' + new AvailableLocaleResolver(getClass().classLoader, Locale.forLanguageTag('en'), true) + .availableLocales.every { it.language in isoLanguages } + } + void 'sorts the discovered locales by their display name in their own language'() { given: List expectedKnown = [ diff --git a/grails-i18n/src/test/resources/spring-security-core_zu.properties b/grails-i18n/src/test/resources/spring-security-core_zu.properties new file mode 100644 index 00000000000..086ad55346e --- /dev/null +++ b/grails-i18n/src/test/resources/spring-security-core_zu.properties @@ -0,0 +1 @@ +default.greeting=Sawubona From 170ae02d0f4fad06b96c1f5feb4fe5210a930db7 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 9 Jul 2026 17:34:15 -0700 Subject: [PATCH 3/4] Cover available-locale wiring, plugin lifecycle and taglib branches Addresses the test-coverage review on #15940: - I18nAutoConfigurationSpec: assert the availableLocaleResolver bean registers by default, honours grails.i18n.default.locale (underscore form) and grails.i18n.availableLocales.includePlugins, and backs off to a user-defined AvailableLocaleResolver bean - New I18nGrailsPluginSpec: doWithApplicationContext publishes the resolved locales to the servlet context, is a no-op outside a WebApplicationContext or without the resolver bean, and onChange clears the resolver cache and republishes a freshly computed list - AvailableLocaleResolverSpec: null and language-less default locales are not added, classpath scan failure falls back to the default locale only, and deliberate malformed-bundle fixtures (no suffix, empty suffix, non-ISO suffix) never contribute a locale - LocaleSelectAvailableSpec: an explicit available="false" keeps the full JVM locale list - I18nGrailsPlugin.onChange: null-guard BuildSettings.RESOURCES_DIR, which is null outside a Grails build --- .../taglib/LocaleSelectAvailableSpec.groovy | 12 ++ .../plugins/i18n/I18nGrailsPlugin.groovy | 3 +- .../i18n/AvailableLocaleResolverSpec.groovy | 46 +++++++ .../i18n/I18nAutoConfigurationSpec.groovy | 40 ++++++ .../plugins/i18n/I18nGrailsPluginSpec.groovy | 122 ++++++++++++++++++ .../test/resources/bogus-plugin_zz.properties | 1 + .../src/test/resources/messages_.properties | 1 + .../src/test/resources/standalone.properties | 1 + 8 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nGrailsPluginSpec.groovy create mode 100644 grails-i18n/src/test/resources/bogus-plugin_zz.properties create mode 100644 grails-i18n/src/test/resources/messages_.properties create mode 100644 grails-i18n/src/test/resources/standalone.properties diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LocaleSelectAvailableSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LocaleSelectAvailableSpec.groovy index 4aa69c400d2..591f73aa493 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LocaleSelectAvailableSpec.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LocaleSelectAvailableSpec.groovy @@ -54,6 +54,18 @@ class LocaleSelectAvailableSpec extends Specification implements TagLibUnitTest< output.contains('value="fr"') } + void 'an explicit available="false" keeps the full JVM locale list even when locales are published'() { + given: + request.servletContext.setAttribute('availableLocales', + [Locale.forLanguageTag('en'), Locale.forLanguageTag('es')]) + + when: + String output = applyTemplate('') + + then: 'a locale outside the published list is still offered' + output.contains('value="fr"') + } + void 'available="true" falls back to the current locale when nothing is published'() { when: String output = applyTemplate('') diff --git a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy index ad59a1aba5e..c1d9db0de0e 100644 --- a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy +++ b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy @@ -80,7 +80,8 @@ class I18nGrailsPlugin extends Plugin { def resourcesDir = BuildSettings.RESOURCES_DIR def classesDir = BuildSettings.CLASSES_DIR - if (resourcesDir.exists() && event.source instanceof FileSystemResource) { + // RESOURCES_DIR is null outside a Grails build (e.g. unit tests); nothing to copy then + if (resourcesDir?.exists() && event.source instanceof FileSystemResource) { // this MUST be getFile() because there's also a isFile() on this class File eventFile = (event.source as FileSystemResource).getFile().canonicalFile File i18nDir = eventFile.parentFile diff --git a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy index c86a4c9c5f6..c1fa2ab8bc3 100644 --- a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy +++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy @@ -96,6 +96,52 @@ class AvailableLocaleResolverSpec extends Specification { thrown(UnsupportedOperationException) } + void 'a null default locale is simply not added'() { + when: + List locales = newResolver(null).availableLocales + + then: 'the scanned bundles are still discovered' + locales.contains(Locale.forLanguageTag('es')) + locales.contains(Locale.forLanguageTag('fr')) + + and: 'no null or language-less entry is present' + locales.every { it != null && it.language } + } + + void 'a language-less default locale (Locale.ROOT) is simply not added'() { + expect: + !newResolver(Locale.ROOT).availableLocales.contains(Locale.ROOT) + } + + void 'falls back to just the default locale when classpath scanning fails'() { + given: 'a class loader whose resource enumeration always fails' + ClassLoader failingClassLoader = new ClassLoader(null) { + + @Override + Enumeration getResources(String name) throws IOException { + throw new IOException('simulated classpath failure') + } + } + + when: + List locales = new AvailableLocaleResolver(failingClassLoader, Locale.forLanguageTag('en')).availableLocales + + then: 'no exception escapes and only the default locale is offered' + locales == [Locale.forLanguageTag('en')] + } + + void 'bundles with no suffix, an empty suffix or a non-ISO suffix never contribute a locale'() { + given: 'deliberate fixtures: standalone.properties, messages_.properties and bogus-plugin_zz.properties' + List locales = new AvailableLocaleResolver(getClass().classLoader, null, true).availableLocales + + expect: 'the scan saw sibling fixtures (a valid plugin-namespaced bundle is discovered)' + locales.contains(Locale.forLanguageTag('zu')) + + and: 'none of the malformed fixtures produced a locale' + locales.every { it.language && it.language in Locale.getISOLanguages() } + !locales.any { it.language == 'zz' } + } + void 'caches the computed list until the cache is cleared'() { given: AvailableLocaleResolver resolver = newResolver() diff --git a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy index 8fdcc0a3f7a..b1ed8b33725 100644 --- a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy +++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy @@ -155,6 +155,46 @@ class I18nAutoConfigurationSpec extends Specification { parent.close() } + void 'the availableLocaleResolver bean registers by default and includes plugin bundles'() { + expect: + contextRunner().run { context -> + def resolver = context.getBean(AvailableLocaleResolver) + // the default locale defaults to en, and includePlugins defaults to true so the + // plugin-namespaced spring-security-core_zu.properties fixture is discovered + assert resolver.availableLocales.contains(Locale.forLanguageTag('en')) + assert resolver.availableLocales.contains(Locale.forLanguageTag('zu')) + } + } + + void 'grails.i18n.default.locale and availableLocales.includePlugins drive the availableLocaleResolver'() { + expect: + contextRunner() + .withPropertyValues( + 'grails.i18n.default.locale=pt_BR', + 'grails.i18n.availableLocales.includePlugins=false') + .run { context -> + def resolver = context.getBean(AvailableLocaleResolver) + // the underscore form is normalised to a language tag + assert resolver.availableLocales.contains(Locale.forLanguageTag('pt-BR')) + // plugin-namespaced bundles are excluded when includePlugins=false + assert !resolver.availableLocales.contains(Locale.forLanguageTag('zu')) + } + } + + void 'a user-defined AvailableLocaleResolver bean makes the Grails availableLocaleResolver back off'() { + given: + AvailableLocaleResolver userResolver = new AvailableLocaleResolver(getClass().classLoader, Locale.forLanguageTag('en')) + Supplier userResolverSupplier = () -> userResolver + + expect: + contextRunner() + .withBean('availableLocaleResolver', AvailableLocaleResolver, userResolverSupplier) + .run { context -> + assert context.getBean(AvailableLocaleResolver).is(userResolver) + assert context.getBeanNamesForType(AvailableLocaleResolver).length == 1 + } + } + void 'a user-defined messageSource bean makes the Grails messageSource back off'() { given: MessageSource userMessageSource = new StaticMessageSource() diff --git a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nGrailsPluginSpec.groovy b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nGrailsPluginSpec.groovy new file mode 100644 index 00000000000..643a1f8436c --- /dev/null +++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nGrailsPluginSpec.groovy @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.plugins.i18n + +import grails.core.DefaultGrailsApplication + +import org.springframework.context.support.GenericApplicationContext +import org.springframework.mock.web.MockServletContext +import org.springframework.web.context.support.StaticWebApplicationContext + +import spock.lang.Specification + +/** + * Tests the available-locale publishing lifecycle of {@link I18nGrailsPlugin}: the discovered + * locales are published to the servlet context on startup and republished (after a re-scan) + * when a message bundle changes. + */ +class I18nGrailsPluginSpec extends Specification { + + private static StaticWebApplicationContext webContext(AvailableLocaleResolver resolver = null) { + StaticWebApplicationContext ctx = new StaticWebApplicationContext() + ctx.servletContext = new MockServletContext() + if (resolver != null) { + ctx.beanFactory.registerSingleton('availableLocaleResolver', resolver) + } + // StaticApplicationContext already registers a StaticMessageSource under 'messageSource', + // which is what onChange() looks up + ctx.refresh() + ctx + } + + private static I18nGrailsPlugin pluginFor(StaticWebApplicationContext ctx) { + I18nGrailsPlugin plugin = new I18nGrailsPlugin() + plugin.applicationContext = ctx + plugin.grailsApplication = new DefaultGrailsApplication() + plugin + } + + void 'doWithApplicationContext publishes the available locales to the servlet context'() { + given: + AvailableLocaleResolver resolver = new AvailableLocaleResolver(getClass().classLoader, Locale.forLanguageTag('en')) + StaticWebApplicationContext ctx = webContext(resolver) + I18nGrailsPlugin plugin = pluginFor(ctx) + + when: + plugin.doWithApplicationContext() + + then: 'views read the exact list the resolver computed' + ctx.servletContext.getAttribute('availableLocales').is(resolver.availableLocales) + + cleanup: + ctx.close() + } + + void 'doWithApplicationContext is a no-op outside a web application context'() { + given: + GenericApplicationContext ctx = new GenericApplicationContext() + ctx.refresh() + I18nGrailsPlugin plugin = new I18nGrailsPlugin() + plugin.applicationContext = ctx + + when: + plugin.doWithApplicationContext() + + then: + notThrown(Exception) + + cleanup: + ctx.close() + } + + void 'doWithApplicationContext publishes nothing when no availableLocaleResolver bean exists'() { + given: + StaticWebApplicationContext ctx = webContext() + I18nGrailsPlugin plugin = pluginFor(ctx) + + when: + plugin.doWithApplicationContext() + + then: + ctx.servletContext.getAttribute('availableLocales') == null + + cleanup: + ctx.close() + } + + void 'onChange re-scans and republishes the available locales when a bundle changes'() { + given: 'the locales have been published once and are cached' + AvailableLocaleResolver resolver = new AvailableLocaleResolver(getClass().classLoader, Locale.forLanguageTag('en')) + StaticWebApplicationContext ctx = webContext(resolver) + I18nGrailsPlugin plugin = pluginFor(ctx) + plugin.doWithApplicationContext() + List before = (List) ctx.servletContext.getAttribute('availableLocales') + + when: 'a bundle-change event arrives (a non-file source, so no bundle copying is attempted)' + plugin.onChange([source: 'messages_es.properties'] as Map) + + then: 'a freshly computed list with the same contents is republished' + List after = (List) ctx.servletContext.getAttribute('availableLocales') + !after.is(before) + after == before + + cleanup: + ctx.close() + } +} diff --git a/grails-i18n/src/test/resources/bogus-plugin_zz.properties b/grails-i18n/src/test/resources/bogus-plugin_zz.properties new file mode 100644 index 00000000000..4592dd6ea28 --- /dev/null +++ b/grails-i18n/src/test/resources/bogus-plugin_zz.properties @@ -0,0 +1 @@ +fixture=a bundle whose suffix (zz) is not an ISO language; must never contribute a locale diff --git a/grails-i18n/src/test/resources/messages_.properties b/grails-i18n/src/test/resources/messages_.properties new file mode 100644 index 00000000000..83e3b9581e1 --- /dev/null +++ b/grails-i18n/src/test/resources/messages_.properties @@ -0,0 +1 @@ +fixture=a bundle with an empty locale suffix; must never contribute a locale diff --git a/grails-i18n/src/test/resources/standalone.properties b/grails-i18n/src/test/resources/standalone.properties new file mode 100644 index 00000000000..cd45d346894 --- /dev/null +++ b/grails-i18n/src/test/resources/standalone.properties @@ -0,0 +1 @@ +fixture=a properties file with no locale suffix; must never contribute a locale From 9d79f67d092189d5d6913e462f7c3093ecb6fade Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 9 Jul 2026 19:03:01 -0700 Subject: [PATCH 4/4] Address review: type-based resolver lookup, consistent default locale, stricter suffix validation - I18nGrailsPlugin now resolves AvailableLocaleResolver by type via getBeanProvider instead of the 'availableLocaleResolver' bean name, so a user-defined resolver registered under any name (the back-off contract of @ConditionalOnMissingBean) is still published; covered by a new custom-bean-name test - I18nAutoConfiguration reuses the existing grails.i18n.default.locale field and fixedLocale() (StringUtils.parseLocale, JVM-default fallback) for the availableLocaleResolver bean instead of a second 'en' default parsed with forLanguageTag - AvailableLocaleResolver validates bundle suffixes by resource-bundle convention language(_COUNTRY(_variant)): the country must be an ISO country, so foo_en_prod.properties no longer misparses as an en-Prod locale; new report_en_prod.properties fixture and country-parsing test - create-app/web-profile layouts highlight the active language by language subtag so the highlight also fires when the request locale carries a country (e.g. en_US vs en) - Reorder the changingLocales docs so the custom-switcher example directly follows the sentence introducing it --- .../src/en/guide/i18n/changingLocales.adoc | 17 ++++++------ .../src/main/resources/gsp/main.gsp | 2 +- .../plugins/i18n/AvailableLocaleResolver.java | 27 ++++++++++++++----- .../plugins/i18n/I18nAutoConfiguration.java | 10 +++---- .../plugins/i18n/I18nGrailsPlugin.groovy | 14 +++++++--- .../i18n/AvailableLocaleResolverSpec.groovy | 13 +++++++-- .../i18n/I18nAutoConfigurationSpec.groovy | 5 ++-- .../plugins/i18n/I18nGrailsPluginSpec.groovy | 21 +++++++++++++-- .../test/resources/report_en_prod.properties | 1 + .../grails-app/views/layouts/main.gsp | 2 +- 10 files changed, 79 insertions(+), 33 deletions(-) create mode 100644 grails-i18n/src/test/resources/report_en_prod.properties diff --git a/grails-doc/src/en/guide/i18n/changingLocales.adoc b/grails-doc/src/en/guide/i18n/changingLocales.adoc index cf331ae5966..db01b7255c6 100644 --- a/grails-doc/src/en/guide/i18n/changingLocales.adoc +++ b/grails-doc/src/en/guide/i18n/changingLocales.adoc @@ -28,7 +28,14 @@ Grails will automatically switch the user's locale and subsequent requests will To offer a language selector, the i18n plugin discovers the locales your application is actually translated into by scanning the classpath for `messages_*.properties` bundles (plus the default locale, configurable via `grails.i18n.default.locale`). This list is exposed as the `AvailableLocaleResolver` bean and published to the servlet context under the `availableLocales` attribute. The `` tag renders a `