From 435a7475191a60900e015323d3816afb484c0fd0 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Wed, 16 Jun 2021 15:33:10 +0200 Subject: [PATCH] Type-safe messages - support logical lines in localized files - resolves #17925 --- docs/src/main/asciidoc/qute-reference.adoc | 22 +++++-- .../deployment/MessageBundleProcessor.java | 58 ++++++++++++++----- .../i18n/MessageBundleLogicalLineTest.java | 56 ++++++++++++++++++ .../test/resources/messages/msg_cs.properties | 9 +++ 4 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/MessageBundleLogicalLineTest.java create mode 100644 extensions/qute/deployment/src/test/resources/messages/msg_cs.properties diff --git a/docs/src/main/asciidoc/qute-reference.adoc b/docs/src/main/asciidoc/qute-reference.adoc index 102c8ac13db01..4b1a6b2a9c291 100644 --- a/docs/src/main/asciidoc/qute-reference.adoc +++ b/docs/src/main/asciidoc/qute-reference.adoc @@ -1713,22 +1713,36 @@ public interface GermanAppMessages extends AppMessages { 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 `#`). -Keys are mapped to method names from the corresponding message bundle interface. +Blank lines are ignored. +Keys are _mapped to method names_ from the corresponding message bundle interface. Values represent the templates normally defined by `io.quarkus.qute.i18n.Message#value()`. -We use `.properties` suffix in our example because most IDEs and text editors support syntax highlighting of `.properties` files. -But in fact, the suffix could be anything. +A value may be spread out across several adjacent normal lines. +In such case, the line terminator must be escaped with a backslash character `\`. +The behavior is very similar to the behavior of the `java.util.Properties.load(Reader)` method. .Localized File Example - `msg_de.properties` [source,properties] ---- +# This comment is ignored hello_name=Hallo {name}! <1> <2> ---- <1> Each line in a localized file represents a key/value pair. The key must correspond to a method declared on the message bundle interface. The value is the message template. <2> Keys and values are separated by the equals sign. +NOTE: We use the `.properties` suffix in our example because most IDEs and text editors support syntax highlighting of `.properties` files. But in fact, the suffix could be anything - it is just ignored. + TIP: An example properties file is generated into the target directory for each message bundle interface automatically. For example, by default if no name is specified for `@MessageBundle` the file `target/qute-i18n-examples/msg.properties` is generated when the application is build via `mvn clean package`. You can use this file as a base for a specific locale. Just rename the file - e.g. `msg_fr.properties`, change the message templates and move it in the `src/main/resources/messages` directory. -Once we have the localized bundles defined we need a way to _select_ a correct bundle. +.Value Spread Out Across Several Adjacent Lines +[source,properties] +---- +hello=Hello \ + {name} and \ + good morning! +---- +Note that the line terminator is escaped with a backslash character `\` and white space at the start of the following line is ignored. I.e. `{msg:hello('Edgar')}` would be rendered as `Hello Edgar and good morning!`. + +Once we have the localized bundles defined we need a way to _select_ the correct bundle. If you use a message bundle expression in a template you'll have to specify the `locale` attribute of a template instance. .`locale` Attribute Example 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 132f2e58f5418..9a21dfb5f603f 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 @@ -15,6 +15,7 @@ import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; +import java.util.ListIterator; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; @@ -546,25 +547,34 @@ private Map generateImplementations(List for (Entry entry : bundle.getLocalizedFiles().entrySet()) { Path localizedFile = entry.getValue(); Map keyToTemplate = new HashMap<>(); - int linesProcessed = 0; - for (String line : Files.readAllLines(localizedFile)) { - linesProcessed++; - if (!line.startsWith("#")) { - int eqIndex = line.indexOf('='); - if (eqIndex == -1) { - throw new MessageBundleException( - "Missing key/value separator\n\t- file: " + localizedFile + "\n\t- line " + linesProcessed); - } - String key = line.substring(0, eqIndex).trim(); - if (!hasMessageBundleMethod(bundleInterface, key)) { - throw new MessageBundleException( - "Message bundle method " + key + "() not found on: " + bundleInterface + "\n\t- file: " - + localizedFile + "\n\t- line " + linesProcessed); - } - String value = line.substring(eqIndex + 1, line.length()).trim(); + for (ListIterator it = Files.readAllLines(localizedFile).listIterator(); it.hasNext();) { + String line = it.next(); + if (line.startsWith("#") || line.isBlank()) { + // Comments and blank lines are skipped + continue; + } + int eqIdx = line.indexOf('='); + if (eqIdx == -1) { + throw new MessageBundleException( + "Missing key/value separator\n\t- file: " + localizedFile + "\n\t- line " + it.previousIndex()); + } + String key = line.substring(0, eqIdx).strip(); + if (!hasMessageBundleMethod(bundleInterface, key)) { + throw new MessageBundleException( + "Message bundle method " + key + "() not found on: " + bundleInterface + "\n\t- file: " + + localizedFile + "\n\t- line " + it.previousIndex()); + } + String value = adaptLine(line.substring(eqIdx + 1, line.length())); + if (value.endsWith("\\")) { + // The logical line is spread out across several normal lines + StringBuilder builder = new StringBuilder(value.substring(0, value.length() - 1)); + constructLine(builder, it); + keyToTemplate.put(key, builder.toString()); + } else { keyToTemplate.put(key, value); } } + String locale = entry.getKey(); ClassOutput localeAwareGizmoAdaptor = new GeneratedClassGizmoAdaptor(generatedClasses, new AppClassPredicate(applicationArchivesBuildItem, @@ -587,6 +597,22 @@ public String apply(String className) { return generatedTypes; } + private void constructLine(StringBuilder builder, Iterator it) { + if (it.hasNext()) { + String nextLine = adaptLine(it.next()); + if (nextLine.endsWith("\\")) { + builder.append(nextLine.substring(0, nextLine.length() - 1)); + constructLine(builder, it); + } else { + builder.append(nextLine); + } + } + } + + private String adaptLine(String line) { + return line.stripLeading().replace("\\n", "\n"); + } + private boolean hasMessageBundleMethod(ClassInfo bundleInterface, String name) { for (MethodInfo method : bundleInterface.methods()) { if (method.name().equals(name) && method.hasAnnotation(Names.MESSAGE)) { diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/MessageBundleLogicalLineTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/MessageBundleLogicalLineTest.java new file mode 100644 index 0000000000000..5f6a6035bd919 --- /dev/null +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/i18n/MessageBundleLogicalLineTest.java @@ -0,0 +1,56 @@ +package io.quarkus.qute.deployment.i18n; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Locale; + +import javax.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.qute.Template; +import io.quarkus.qute.i18n.Message; +import io.quarkus.qute.i18n.MessageBundle; +import io.quarkus.qute.i18n.MessageBundles; +import io.quarkus.test.QuarkusUnitTest; + +public class MessageBundleLogicalLineTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(Messages.class) + .addAsResource("messages/msg_cs.properties") + .addAsResource(new StringAsset( + "{msg:hello('Edgar')} {msg:helloNextLine('Edgar')} ::{msg:fruits}"), + "templates/foo.html")); + + @Inject + Template foo; + + @Test + public void testResolvers() { + assertEquals("Hello Edgar! Hello \n Edgar! ::apple, banana, pear, watermelon, kiwi, mango", + foo.render()); + assertEquals("Ahoj Edgar a dobrĂ½ den! Ahoj \n Edgar! ::apple, banana, pear, watermelon, kiwi, mango", + foo.instance().setAttribute(MessageBundles.ATTRIBUTE_LOCALE, Locale.forLanguageTag("cs")).render()); + } + + @MessageBundle(locale = "en") + public interface Messages { + + @Message("Hello {name}!") + String hello(String name); + + @Message("Hello \n {name}!") + String helloNextLine(String name); + + @Message("apple, banana, pear, watermelon, kiwi, mango") + String fruits(); + } + +} diff --git a/extensions/qute/deployment/src/test/resources/messages/msg_cs.properties b/extensions/qute/deployment/src/test/resources/messages/msg_cs.properties new file mode 100644 index 0000000000000..4b54f8bf586b8 --- /dev/null +++ b/extensions/qute/deployment/src/test/resources/messages/msg_cs.properties @@ -0,0 +1,9 @@ +hello=Ahoj \ + {name} a \ + dobrĂ½ den! + +helloNextLine=Ahoj \n {name}! + fruits = apple, banana, pear, \ + watermelon, \ + kiwi, mango + \ No newline at end of file