diff --git a/docs/src/main/asciidoc/qute-reference.adoc b/docs/src/main/asciidoc/qute-reference.adoc index 37db9bd02d02d..8f7d3be91bd52 100644 --- a/docs/src/main/asciidoc/qute-reference.adoc +++ b/docs/src/main/asciidoc/qute-reference.adoc @@ -2067,9 +2067,9 @@ However, the `io.quarkus.qute.i18n.MessageBundle#locale()` can be used to specif Additionally, there are two ways to define a localized bundle: 1. Create an interface that extends the default interface that is annotated with `@Localized` -2. Create an UTF-8 encoded file located in `src/main/resources/messages`; e.g. `msg_de.properties`. +2. Create an UTF-8 encoded file located in the `src/main/resources/messages` directory of an application archive; e.g. `msg_de.properties`. -TIP: A localized interface is the preferred solution mainly due to the possibility of easy refactoring. +TIP: While a localized interface enables easy refactoring an external file might be more convenient in many situations. .Localized Interface Example [source,java] @@ -2088,7 +2088,7 @@ public interface GermanAppMessages extends AppMessages { <1> The value is the locale tag string (IETF). <2> The value is the localized template. -Message bundle files must be encoded in UTF-8. +Message bundle files must be encoded in _UTF-8_. The file name consists of the relevant bundle name (e.g. `msg`) and underscore followed by the locate tag (IETF). The file format is very simple: each line represents either a key/value pair with the equals sign used as a separator or a comment (line starts with `#`). Blank lines are ignored. diff --git a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/MessageBundleProcessor.java b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/MessageBundleProcessor.java index 8fb9f566c3fd8..02e2614b34bd5 100644 --- a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/MessageBundleProcessor.java +++ b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/MessageBundleProcessor.java @@ -51,6 +51,7 @@ import io.quarkus.arc.processor.Annotations; import io.quarkus.arc.processor.BeanInfo; import io.quarkus.arc.processor.DotNames; +import io.quarkus.deployment.ApplicationArchive; import io.quarkus.deployment.GeneratedClassGizmoAdaptor; import io.quarkus.deployment.IsNormal; import io.quarkus.deployment.annotations.BuildProducer; @@ -123,7 +124,7 @@ List processBundles(BeanArchiveIndexBuildItem beanArchiv Map found = new HashMap<>(); List bundles = new ArrayList<>(); List localizedInterfaces = new ArrayList<>(); - Set messageFiles = findMessageFiles(applicationArchivesBuildItem, watchedFiles); + List messageFiles = findMessageFiles(applicationArchivesBuildItem, watchedFiles); // First collect all interfaces annotated with @MessageBundle for (AnnotationInstance bundleAnnotation : index.getAnnotations(Names.BUNDLE)) { @@ -1054,32 +1055,66 @@ private String getDefaultLocale(AnnotationInstance bundleAnnotation, LocalesBuil return defaultLocale; } - private Set findMessageFiles(ApplicationArchivesBuildItem applicationArchivesBuildItem, + private List findMessageFiles(ApplicationArchivesBuildItem applicationArchivesBuildItem, BuildProducer watchedFiles) throws IOException { - return applicationArchivesBuildItem.getRootArchive().apply(tree -> { - final Path messagesPath = tree.getPath(MESSAGES); - if (messagesPath == null) { - return Collections.emptySet(); - } - Set messageFiles = new HashSet<>(); - try (Stream files = Files.list(messagesPath)) { - Iterator iter = files.iterator(); - while (iter.hasNext()) { - Path filePath = iter.next(); - if (Files.isRegularFile(filePath)) { - messageFiles.add(filePath); - String messageFilePath = messagesPath.relativize(filePath).toString(); - if (File.separatorChar != '/') { - messageFilePath = messageFilePath.replace(File.separatorChar, '/'); + + Map> messageFileNameToPath = new HashMap<>(); + + for (ApplicationArchive archive : applicationArchivesBuildItem.getAllApplicationArchives()) { + archive.accept(tree -> { + final Path messagesPath = tree.getPath(MESSAGES); + if (messagesPath == null) { + return; + } + try (Stream files = Files.list(messagesPath)) { + Iterator iter = files.iterator(); + while (iter.hasNext()) { + Path filePath = iter.next(); + if (Files.isRegularFile(filePath)) { + String messageFileName = messagesPath.relativize(filePath).toString(); + if (File.separatorChar != '/') { + messageFileName = messageFileName.replace(File.separatorChar, '/'); + } + List paths = messageFileNameToPath.get(messageFileName); + if (paths == null) { + paths = new ArrayList<>(); + messageFileNameToPath.put(messageFileName, paths); + } + paths.add(filePath); } - watchedFiles.produce(new HotDeploymentWatchedFileBuildItem(MESSAGES + "/" + messageFilePath)); } + } catch (IOException e) { + throw new UncheckedIOException(e); } - } catch (IOException e) { - throw new UncheckedIOException(e); + }); + } + + if (messageFileNameToPath.isEmpty()) { + return Collections.emptyList(); + } + + // Check duplicates + List>> duplicates = messageFileNameToPath.entrySet().stream() + .filter(e -> e.getValue().size() > 1).collect(Collectors.toList()); + if (!duplicates.isEmpty()) { + StringBuilder builder = new StringBuilder("Duplicate localized files found:"); + for (Entry> e : duplicates) { + builder.append("\n\t- ") + .append(e.getKey()) + .append(": ") + .append(e.getValue()); } - return messageFiles; - }); + throw new IllegalStateException(builder.toString()); + } + + // Hot deployment + for (String messageFileName : messageFileNameToPath.keySet()) { + watchedFiles.produce(new HotDeploymentWatchedFileBuildItem(MESSAGES + "/" + messageFileName)); + } + + List messageFiles = new ArrayList<>(); + messageFileNameToPath.values().forEach(messageFiles::addAll); + return messageFiles; } private static class AppClassPredicate implements Predicate { diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/LocalizedFileDuplicateFoundTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/LocalizedFileDuplicateFoundTest.java new file mode 100644 index 0000000000000..e97b0d3b6a267 --- /dev/null +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/LocalizedFileDuplicateFoundTest.java @@ -0,0 +1,40 @@ +package io.quarkus.qute.deployment.i18n; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class LocalizedFileDuplicateFoundTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot(root -> root.addAsResource(new StringAsset("hello=Ahoj!"), "messages/messages_cs.properties")) + .withAdditionalDependency( + d -> d.addAsResource(new StringAsset("hello=Cau!"), "messages/messages_cs.properties")) + .assertException(t -> { + Throwable e = t; + IllegalStateException ise = null; + while (e != null) { + if (e instanceof IllegalStateException) { + ise = (IllegalStateException) e; + break; + } + e = e.getCause(); + } + assertNotNull(ise); + assertTrue(ise.getMessage().contains("Duplicate localized files found:"), ise.getMessage()); + assertTrue(ise.getMessage().contains("messages_cs.properties"), ise.getMessage()); + }); + + @Test + public void testValidation() { + fail(); + } + +} diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/LocalizedFileOutsideRootTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/LocalizedFileOutsideRootTest.java new file mode 100644 index 0000000000000..a7e2a13155e48 --- /dev/null +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/LocalizedFileOutsideRootTest.java @@ -0,0 +1,29 @@ +package io.quarkus.qute.deployment.i18n; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.qute.i18n.Localized; +import io.quarkus.test.QuarkusUnitTest; + +public class LocalizedFileOutsideRootTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot(root -> root.addClass(BravoMessages.class)) + .overrideConfigKey("quarkus.default-locale", "en") + .withAdditionalDependency( + d -> d.addAsResource(new StringAsset("hello=Ahoj!"), "messages/msg_cs.properties")); + + @Localized("cs") + BravoMessages messages; + + @Test + public void testLocalizedFile() { + assertEquals("Ahoj!", messages.hello()); + } + +}