.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
+ * bundle plus the configured default locale, making it suitable for driving a language
+ * 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 the ISO language codes (and the ISO
+ * country codes when a country is present), so non-i18n properties files (e.g.
+ * {@code application.properties} or {@code foo_en_prod.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);
+
+ /** 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 Set ISO_LANGUAGES = new HashSet<>(Arrays.asList(Locale.getISOLanguages()));
+
+ private static final Set ISO_COUNTRIES = new HashSet<>(Arrays.asList(Locale.getISOCountries()));
+
+ 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;
+ }
+
+ /**
+ * @return an unmodifiable, display-name-sorted list of the locales the application is
+ * translated into. Computed once and cached until {@link #clearCache()} is called.
+ */
+ public List getAvailableLocales() {
+ List locales = this.cachedLocales;
+ if (locales == null) {
+ synchronized (this) {
+ locales = this.cachedLocales;
+ if (locales == null) {
+ locales = computeAvailableLocales();
+ this.cachedLocales = locales;
+ }
+ }
+ }
+ return locales;
+ }
+
+ /**
+ * Discards the cached list so the next {@link #getAvailableLocales()} re-scans the classpath.
+ */
+ public void clearCache() {
+ this.cachedLocales = null;
+ }
+
+ private List computeAvailableLocales() {
+ Set locales = new LinkedHashSet<>();
+ 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(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: {}", 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). The suffix follows the resource-bundle
+ * convention {@code language(_COUNTRY(_variant))} — not BCP 47 — so the language must
+ * be an ISO language and, when present, the country an ISO country; anything else (e.g.
+ * {@code foo_en_prod.properties}) is rejected rather than misparsed as a script or region.
+ */
+ 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[] parts = base.substring(underscore + 1).split("_", 3);
+ String language = parts[0];
+ if (!ISO_LANGUAGES.contains(language)) {
+ return null;
+ }
+ if (parts.length == 1) {
+ return Locale.of(language);
+ }
+ String country = parts[1];
+ if (!ISO_COUNTRIES.contains(country)) {
+ return null;
+ }
+ return (parts.length == 2) ? Locale.of(language, country) : Locale.of(language, country, parts[2]);
+ }
+
+}
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 9ccbd60df1b..7882c51d9bd 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
@@ -130,4 +130,30 @@ public MessageSource messageSource(GrailsApplication grailsApplication, GrailsPl
}
return messageSource;
}
+
+ /**
+ * 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.
+ *
+ *
The base {@code messages.properties} locale is always included, resolved from
+ * {@code grails.i18n.default.locale} exactly like the rest of this class (falling back to the
+ * JVM default locale when the property is not set).
+ *
+ * @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.availableLocales.includePlugins:true}") boolean includePlugins) {
+ return new AvailableLocaleResolver(grailsApplication.getClassLoader(), fixedLocale(), includePlugins);
+ }
}
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 21e75c50c9e..dc3a8a08da8 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
@@ -24,6 +24,7 @@ import groovy.util.logging.Slf4j
import org.springframework.context.support.ReloadableResourceBundleMessageSource
import org.springframework.core.io.FileSystemResource
+import org.springframework.web.context.WebApplicationContext
import grails.plugins.Plugin
import grails.util.BuildSettings
@@ -42,6 +43,35 @@ class I18nGrailsPlugin extends Plugin {
String version = GrailsUtil.getGrailsVersion()
String watchedResources = "file:./${baseDir}/**/*.properties".toString()
+ /**
+ * Publishes the discovered available locales to the servlet context so that views and the
+ * {@code g:localeSelect available="true"} tag can render a language selector. Reading a servlet
+ * context attribute keeps consumers decoupled from this module.
+ */
+ static final String AVAILABLE_LOCALES_ATTRIBUTE = 'availableLocales'
+
+ @Override
+ void doWithApplicationContext() {
+ publishAvailableLocales()
+ }
+
+ private void publishAvailableLocales() {
+ def ctx = applicationContext
+ if (!(ctx instanceof WebApplicationContext)) {
+ return
+ }
+ def servletContext = ((WebApplicationContext) ctx).servletContext
+ if (servletContext == null) {
+ return
+ }
+ // resolve by type, not name: the auto-configuration backs off by type, so a user-defined
+ // resolver registered under any bean name must still be published
+ AvailableLocaleResolver resolver = ctx.getBeanProvider(AvailableLocaleResolver).getIfAvailable()
+ if (resolver != null) {
+ servletContext.setAttribute(AVAILABLE_LOCALES_ATTRIBUTE, resolver.availableLocales)
+ }
+ }
+
@Override
void onChange(Map event) {
def ctx = applicationContext
@@ -55,7 +85,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
@@ -93,6 +124,13 @@ class I18nGrailsPlugin extends Plugin {
if (messageSource instanceof ReloadableResourceBundleMessageSource) {
messageSource.clearCache()
}
+
+ // A bundle may have been added/removed, so re-scan and re-publish the available locales.
+ AvailableLocaleResolver availableLocaleResolver = ctx.getBeanProvider(AvailableLocaleResolver).getIfAvailable()
+ if (availableLocaleResolver != null) {
+ availableLocaleResolver.clearCache()
+ publishAvailableLocales()
+ }
}
protected boolean isChildOfFile(File child, File parent) {
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
new file mode 100644
index 00000000000..f4108eb01dd
--- /dev/null
+++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/AvailableLocaleResolverSpec.groovy
@@ -0,0 +1,170 @@
+/*
+ * 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 spock.lang.Specification
+
+/**
+ * Tests {@link AvailableLocaleResolver} against the {@code messages_*.properties} bundles on the
+ * test classpath ({@code src/test/resources}: es, fr, pt_BR, plus the base {@code messages.properties}).
+ */
+class AvailableLocaleResolverSpec extends Specification {
+
+ private AvailableLocaleResolver newResolver(Locale defaultLocale = Locale.forLanguageTag('en')) {
+ new AvailableLocaleResolver(getClass().classLoader, defaultLocale)
+ }
+
+ void 'discovers the locales that have a messages bundle plus the default locale'() {
+ when:
+ List locales = newResolver().availableLocales
+
+ then:
+ locales.contains(Locale.forLanguageTag('en'))
+ locales.contains(Locale.forLanguageTag('es'))
+ locales.contains(Locale.forLanguageTag('fr'))
+ 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 = [
+ Locale.forLanguageTag('en'),
+ Locale.forLanguageTag('es'),
+ Locale.forLanguageTag('fr'),
+ Locale.forLanguageTag('pt-BR')
+ ].sort { it.getDisplayName(it) }
+
+ when: 'the discovered list is narrowed to the bundles this test controls'
+ List actualKnown = newResolver().availableLocales.findAll { it in expectedKnown }
+
+ then: 'their relative order matches a display-name sort'
+ actualKnown == expectedKnown
+ }
+
+ void 'always includes the configured default locale even without a matching bundle'() {
+ expect:
+ newResolver(Locale.forLanguageTag('de')).availableLocales.contains(Locale.forLanguageTag('de'))
+ }
+
+ void 'returns an unmodifiable list'() {
+ when:
+ newResolver().availableLocales.add(Locale.forLanguageTag('zz'))
+
+ then:
+ 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 a malformed locale suffix never contribute a locale'() {
+ given: 'deliberate fixtures: standalone.properties (no suffix), messages_.properties (empty), bogus-plugin_zz.properties (non-ISO language) and report_en_prod.properties (non-ISO country)'
+ 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' }
+
+ and: 'a non-ISO country is rejected rather than misparsed as a script or region (no en-Prod)'
+ !locales.any { it.toLanguageTag().toLowerCase().contains('prod') }
+ }
+
+ void 'a language_COUNTRY suffix is parsed by resource-bundle convention'() {
+ expect: 'the pt_BR fixture yields the pt-BR locale, with BR as the country'
+ Locale brazilian = newResolver().availableLocales.find { it.language == 'pt' }
+ brazilian.country == 'BR'
+ }
+
+ void 'caches the computed list until the cache is cleared'() {
+ given:
+ AvailableLocaleResolver resolver = newResolver()
+
+ expect: 'the same instance is returned while cached'
+ resolver.availableLocales.is(resolver.availableLocales)
+
+ when: 'the cache is cleared'
+ List before = resolver.availableLocales
+ resolver.clearCache()
+ List after = resolver.availableLocales
+
+ then: 'a freshly computed list is returned with equal contents'
+ !before.is(after)
+ before == after
+ }
+}
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..7f0bffb95fc 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,47 @@ 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)
+ // without grails.i18n.default.locale the JVM default is included (same fallback the
+ // fixed localeResolver uses), and includePlugins defaults to true so the
+ // plugin-namespaced spring-security-core_zu.properties fixture is discovered
+ assert resolver.availableLocales.contains(Locale.getDefault())
+ 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..a82460dc4c9
--- /dev/null
+++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nGrailsPluginSpec.groovy
@@ -0,0 +1,139 @@
+/*
+ * 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,
+ String beanName = 'availableLocaleResolver') {
+ StaticWebApplicationContext ctx = new StaticWebApplicationContext()
+ ctx.servletContext = new MockServletContext()
+ if (resolver != null) {
+ ctx.beanFactory.registerSingleton(beanName, 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 'a user-defined resolver under a custom bean name is still published (lookup is by type)'() {
+ given: 'the auto-configuration backs off by type, so the plugin must find the bean by type too'
+ AvailableLocaleResolver resolver = new AvailableLocaleResolver(getClass().classLoader, Locale.forLanguageTag('en'))
+ StaticWebApplicationContext ctx = webContext(resolver, 'myOwnLocaleResolver')
+ I18nGrailsPlugin plugin = pluginFor(ctx)
+
+ when:
+ plugin.doWithApplicationContext()
+
+ then:
+ 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..bdad54f1039
--- /dev/null
+++ b/grails-i18n/src/test/resources/messages.properties
@@ -0,0 +1 @@
+default.greeting=Hello
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/messages_es.properties b/grails-i18n/src/test/resources/messages_es.properties
new file mode 100644
index 00000000000..e4079c6510d
--- /dev/null
+++ b/grails-i18n/src/test/resources/messages_es.properties
@@ -0,0 +1 @@
+default.greeting=Hola
diff --git a/grails-i18n/src/test/resources/messages_fr.properties b/grails-i18n/src/test/resources/messages_fr.properties
new file mode 100644
index 00000000000..6da27c16795
--- /dev/null
+++ b/grails-i18n/src/test/resources/messages_fr.properties
@@ -0,0 +1 @@
+default.greeting=Bonjour
diff --git a/grails-i18n/src/test/resources/messages_pt_BR.properties b/grails-i18n/src/test/resources/messages_pt_BR.properties
new file mode 100644
index 00000000000..b14a69faf56
--- /dev/null
+++ b/grails-i18n/src/test/resources/messages_pt_BR.properties
@@ -0,0 +1 @@
+default.greeting=Olá
diff --git a/grails-i18n/src/test/resources/report_en_prod.properties b/grails-i18n/src/test/resources/report_en_prod.properties
new file mode 100644
index 00000000000..5f613e90e06
--- /dev/null
+++ b/grails-i18n/src/test/resources/report_en_prod.properties
@@ -0,0 +1 @@
+fixture=a bundle-like name whose suffix (en_prod) has a non-ISO country; must never contribute a locale
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
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
diff --git a/grails-profiles/web/skeleton/grails-app/views/layouts/main.gsp b/grails-profiles/web/skeleton/grails-app/views/layouts/main.gsp
index c1bd1855cb0..1ff1ca7f281 100644
--- a/grails-profiles/web/skeleton/grails-app/views/layouts/main.gsp
+++ b/grails-profiles/web/skeleton/grails-app/views/layouts/main.gsp
@@ -18,6 +18,22 @@