Skip to content

Commit

Permalink
Qute - default locale and charset
Browse files Browse the repository at this point in the history
- honor quarkus.default-locale and introduce
quarkus.qute.default-charset
- also fail the build if a localized interface does not extend a message
bundle interface
- resolves quarkusio#21405
  • Loading branch information
mkouba committed Jan 5, 2022
1 parent 5ba71b2 commit fb88740
Show file tree
Hide file tree
Showing 11 changed files with 190 additions and 45 deletions.
28 changes: 15 additions & 13 deletions docs/src/main/asciidoc/qute-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -57,29 +57,29 @@ Once we have an `Engine` instance we could parse the template contents:

[source,java]
----
Template helloTemplate = engine.parse(helloHtmlContent);
Template hello = engine.parse(helloHtmlContent);
----

TIP: In Quarkus, you can simply inject the template definition. The template is automatically parsed and cached - see <<quarkus_integration>>.

Finally, we will create a *template instance*, set the data and render the output:
Finally, create a *template instance*, set the data and render the output:

[source,java]
----
// Renders <html><p>Hello Jim!</p></html>
helloTemplate.data("name", "Jim").render(); <1> <2>
hello.data("name", "Jim").render(); <1> <2>
----
<1> `Template.data(String, Object)` is a convenient method that creates a template instance and sets the data in one step.
<2> `TemplateInstance.render()` triggers a synchronous rendering, i.e. the current thread is blocked until the rendering is finished. However, there are also asynchronous ways to trigger the rendering and consume the results. For example there is the `TemplateInstance.renderAsync()` method that returns `CompletionStage<String>` or `TemplateInstance.createMulti()` that returns Mutiny's `Multi<String>`.

So the workflow is simple:

1. Create template contents (`hello.html`),
2. Parse template definition (`io.quarkus.qute.Template`),
3. Create template instance (`io.quarkus.qute.TemplateInstance`),
4. Render output.
1. Create the template contents (`hello.html`),
2. Parse the template definition (`io.quarkus.qute.Template`),
3. Create a template instance (`io.quarkus.qute.TemplateInstance`),
4. Render the output.

TIP: The `Engine` is able to cache the definitions so that it's not necessary to parse the contents again and again. In Quarkus, the caching is done automatically.
TIP: The `Engine` is able to cache the template definitions so that it's not necessary to parse the contents again and again. In Quarkus, the caching is done automatically.

[[core_features]]
== Core Features
Expand Down Expand Up @@ -1073,10 +1073,11 @@ engineBuilder.addValueResolver(ValueResolver.builder()
[[template-locator]]
==== Template Locator

Manual registration is sometimes handy but it's also possible to register a template locator using `EngineBuilder.addLocator(Function<String, Optional<Reader>>)`.
Manual registration is sometimes handy but it's also possible to register a template locator using `EngineBuilder.addLocator()`.
This locator is used whenever the `Engine.getTemplate()` method is called and the engine has no template for a given id stored in the cache.
The locator is responsible to use the correct character encoding when reading the contents of a template.

NOTE: In Quarkus, all templates from the `src/main/resources/templates` are located automatically.
NOTE: In Quarkus, all templates from the `src/main/resources/templates` are located automatically and the encoding set via `quarkus.qute.default-charset` (UTF-8 by default) is used.

==== Content Filters

Expand Down Expand Up @@ -1883,7 +1884,7 @@ NOTE: A warning message is logged for each _unused_ parameter.

==== Localization

The default locale of the Java Virtual Machine used to *build the application* is used for the `@MessageBundle` interface by default.
The default locale specified via the `quarkus.default-locale` config property is used for the `@MessageBundle` interface by default.
However, the `io.quarkus.qute.i18n.MessageBundle#locale()` can be used to specify a custom locale.
Additionally, there are two ways to define a localized bundle:

Expand Down Expand Up @@ -1941,8 +1942,9 @@ hello=Hello \
----
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.
Once we have the localized bundles defined we need a way to _select_ the correct bundle for a specific template instance, i.e. to specify the locale for all message bundle expressions in the template.
By default, the locale specified via the `quarkus.default-locale` configuration property is used to select the bundle.
Alternatively, you can specify the `locale` attribute of a template instance.

.`locale` Attribute Example
[source,java]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
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;
import java.util.Set;
Expand Down Expand Up @@ -92,6 +91,7 @@
import io.quarkus.qute.i18n.MessageBundles;
import io.quarkus.qute.runtime.MessageBundleRecorder;
import io.quarkus.qute.runtime.QuteConfig;
import io.quarkus.runtime.LocalesBuildTimeConfig;
import io.quarkus.runtime.util.StringUtil;

public class MessageBundleProcessor {
Expand All @@ -115,11 +115,13 @@ List<MessageBundleBuildItem> processBundles(BeanArchiveIndexBuildItem beanArchiv
BuildProducer<GeneratedClassBuildItem> generatedClasses, BeanRegistrationPhaseBuildItem beanRegistration,
BuildProducer<BeanConfiguratorBuildItem> configurators,
BuildProducer<MessageBundleMethodBuildItem> messageTemplateMethods,
BuildProducer<HotDeploymentWatchedFileBuildItem> watchedFiles) throws IOException {
BuildProducer<HotDeploymentWatchedFileBuildItem> watchedFiles,
LocalesBuildTimeConfig locales) throws IOException {

IndexView index = beanArchiveIndex.getIndex();
Map<String, ClassInfo> found = new HashMap<>();
List<MessageBundleBuildItem> bundles = new ArrayList<>();
List<DotName> localizedInterfaces = new ArrayList<>();
Set<Path> messageFiles = findMessageFiles(applicationArchivesBuildItem);

Path messagesPath = applicationArchivesBuildItem.getRootArchive().getChildPath(MESSAGES);
Expand Down Expand Up @@ -153,7 +155,7 @@ List<MessageBundleBuildItem> processBundles(BeanArchiveIndexBuildItem beanArchiv
found.put(name, bundleClass);

// Find localizations for each interface
String defaultLocale = getDefaultLocale(bundleAnnotation);
String defaultLocale = getDefaultLocale(bundleAnnotation, locales);
List<ClassInfo> localized = new ArrayList<>();
for (ClassInfo implementor : index.getKnownDirectImplementors(bundleClass.name())) {
if (Modifier.isInterface(implementor.flags())) {
Expand All @@ -169,6 +171,7 @@ List<MessageBundleBuildItem> processBundles(BeanArchiveIndexBuildItem beanArchiv
"A localized message bundle interface [%s] already exists for locale %s: [%s]",
previous != null ? previous : bundleClass, locale, localizedInterface));
}
localizedInterfaces.add(localizedInterface.name());
}

// Find localized files
Expand Down Expand Up @@ -196,6 +199,23 @@ List<MessageBundleBuildItem> processBundles(BeanArchiveIndexBuildItem beanArchiv
}
}

// Detect interfaces annotated with @Localized that don't extend a message bundle interface
for (AnnotationInstance localizedAnnotation : index.getAnnotations(Names.LOCALIZED)) {
if (localizedAnnotation.target().kind() == Kind.CLASS) {
ClassInfo localized = localizedAnnotation.target().asClass();
if (Modifier.isInterface(localized.flags())) {
if (!localizedInterfaces.contains(localized.name())) {
throw new MessageBundleException(
String.format(
"A localized message bundle interface must extend a message bundle interface: "
+ localized));
}
} else {
throw new MessageBundleException("@Localized must be declared on an interface: " + localized);
}
}
}

// Generate implementations
// name -> impl class
Map<String, String> generatedImplementations = generateImplementations(bundles, generatedClasses,
Expand All @@ -207,7 +227,8 @@ List<MessageBundleBuildItem> processBundles(BeanArchiveIndexBuildItem beanArchiv
beanRegistration.getContext().configure(bundleInterface.name()).addType(bundle.getDefaultBundleInterface().name())
// The default message bundle - add both @Default and @Localized
.addQualifier(DotNames.DEFAULT).addQualifier().annotation(Names.LOCALIZED)
.addValue("value", getDefaultLocale(bundleInterface.classAnnotation(Names.BUNDLE))).done().unremovable()
.addValue("value", getDefaultLocale(bundleInterface.classAnnotation(Names.BUNDLE), locales)).done()
.unremovable()
.scope(Singleton.class).creator(mc -> {
// Just create a new instance of the generated class
mc.returnValue(
Expand Down Expand Up @@ -1010,11 +1031,11 @@ private String getKey(MethodInfo method, AnnotationInstance messageAnnotation, A
}
}

private String getDefaultLocale(AnnotationInstance bundleAnnotation) {
private String getDefaultLocale(AnnotationInstance bundleAnnotation, LocalesBuildTimeConfig locales) {
AnnotationValue localeValue = bundleAnnotation.value(BUNDLE_LOCALE);
String defaultLocale;
if (localeValue == null || localeValue.asString().equals(MessageBundle.DEFAULT_LOCALE)) {
defaultLocale = Locale.getDefault().toLanguageTag();
defaultLocale = locales.defaultLocale.toLanguageTag();
} else {
defaultLocale = localeValue.asString();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.lang.reflect.Modifier;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
Expand Down Expand Up @@ -1235,7 +1236,7 @@ void collectTemplates(ApplicationArchivesBuildItem applicationArchives,
if (basePath != null) {
LOGGER.debugf("Found extension templates dir: %s", path);
scan(basePath, basePath, BASE_PATH + "/", watchedPaths, templatePaths, nativeImageResources,
config.templatePathExclude);
config);
break;
}
} else {
Expand All @@ -1244,7 +1245,7 @@ void collectTemplates(ApplicationArchivesBuildItem applicationArchives,
if (Files.exists(basePath)) {
LOGGER.debugf("Found extension templates in: %s", path);
scan(basePath, basePath, BASE_PATH + "/", watchedPaths, templatePaths, nativeImageResources,
config.templatePathExclude);
config);
}
} catch (IOException e) {
LOGGER.warnf(e, "Unable to create the file system from the path: %s", path);
Expand All @@ -1261,7 +1262,7 @@ void collectTemplates(ApplicationArchivesBuildItem applicationArchives,
LOGGER.debugf("Found templates dir: %s", basePath);
basePaths.add(basePath);
scan(basePath, basePath, BASE_PATH + "/", watchedPaths, templatePaths, nativeImageResources,
config.templatePathExclude);
config);
break;
}
}
Expand Down Expand Up @@ -2139,7 +2140,7 @@ public static String getName(InjectionPointInfo injectionPoint) {
private static void produceTemplateBuildItems(BuildProducer<TemplatePathBuildItem> templatePaths,
BuildProducer<HotDeploymentWatchedFileBuildItem> watchedPaths,
BuildProducer<NativeImageResourceBuildItem> nativeImageResources, String basePath, String filePath,
Path originalPath) {
Path originalPath, QuteConfig config) {
if (filePath.isEmpty()) {
return;
}
Expand All @@ -2149,13 +2150,14 @@ private static void produceTemplateBuildItems(BuildProducer<TemplatePathBuildIte
// NOTE: we cannot just drop the template because a template param can be added
watchedPaths.produce(new HotDeploymentWatchedFileBuildItem(fullPath, true));
nativeImageResources.produce(new NativeImageResourceBuildItem(fullPath));
templatePaths.produce(new TemplatePathBuildItem(filePath, originalPath, readTemplateContent(originalPath)));
templatePaths.produce(
new TemplatePathBuildItem(filePath, originalPath, readTemplateContent(originalPath, config.defaultCharset)));
}

private void scan(Path root, Path directory, String basePath, BuildProducer<HotDeploymentWatchedFileBuildItem> watchedPaths,
BuildProducer<TemplatePathBuildItem> templatePaths,
BuildProducer<NativeImageResourceBuildItem> nativeImageResources,
Pattern templatePathExclude)
QuteConfig config)
throws IOException {
try (Stream<Path> files = Files.list(directory)) {
Iterator<Path> iter = files.iterator();
Expand All @@ -2167,15 +2169,15 @@ private void scan(Path root, Path directory, String basePath, BuildProducer<HotD
if (File.separatorChar != '/') {
templatePath = templatePath.replace(File.separatorChar, '/');
}
if (templatePathExclude.matcher(templatePath).matches()) {
if (config.templatePathExclude.matcher(templatePath).matches()) {
LOGGER.debugf("Template file exluded: %s", filePath);
continue;
}
produceTemplateBuildItems(templatePaths, watchedPaths, nativeImageResources, basePath, templatePath,
filePath);
filePath, config);
} else if (Files.isDirectory(filePath)) {
LOGGER.debugf("Scan directory: %s", filePath);
scan(root, filePath, basePath, watchedPaths, templatePaths, nativeImageResources, templatePathExclude);
scan(root, filePath, basePath, watchedPaths, templatePaths, nativeImageResources, config);
}
}
}
Expand Down Expand Up @@ -2226,9 +2228,9 @@ private boolean isApplicationArchive(ResolvedDependency dependency, Set<Applicat
return false;
}

static String readTemplateContent(Path path) {
static String readTemplateContent(Path path, Charset defaultCharset) {
try {
return Files.readString(path);
return Files.readString(path, defaultCharset);
} catch (IOException e) {
throw new UncheckedIOException("Unable to read the template content from path: " + path, e);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.quarkus.qute.deployment.encoding;

import static org.junit.jupiter.api.Assertions.assertEquals;

import javax.inject.Inject;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.qute.Template;
import io.quarkus.test.QuarkusUnitTest;

public class CustomEncodingTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.withApplicationRoot(root -> root
.addAsResource("io/quarkus/qute/deployment/encoding/foo.txt", "templates/foo.txt"))
.overrideConfigKey("quarkus.qute.default-charset", "windows-1250");

@Inject
Template foo;

@Test
public void testEncoding() {
assertEquals("kočka", foo.render().strip());
}

}
Original file line number Diff line number Diff line change
@@ -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.asset.StringAsset;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.qute.Template;
import io.quarkus.qute.i18n.Localized;
import io.quarkus.qute.i18n.Message;
import io.quarkus.qute.i18n.MessageBundle;
import io.quarkus.test.QuarkusUnitTest;

public class MessageBundleCustomDefaultLocaleTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar
.addClasses(Messages.class, EnMessages.class)
.addAsResource(new StringAsset(
"{msg:helloWorld}"),
"templates/foo.html"))
.overrideConfigKey("quarkus.default-locale", "cs_CZ");

@Inject
Template foo;

@Test
public void testResolvers() {
assertEquals("Ahoj světe!", foo.render());
assertEquals("Ahoj světe!", foo.instance().setAttribute("locale", Locale.forLanguageTag("cs")).render());
assertEquals("Hello world!", foo.instance().setAttribute("locale", Locale.ENGLISH).render());
}

@MessageBundle
public interface Messages {

@Message("Ahoj světe!")
String helloWorld();

}

@Localized("en")
public interface EnMessages extends Messages {

@Message("Hello world!")
String helloWorld();

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ko�ka
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
public @interface MessageBundle {

/**
* Constant value for {@link #locale()} indicating that the default locale of the Java Virtual Machine used to build the
* application should be used.
* Constant value for {@link #locale()} indicating that the default locale specified via the {@code quarkus.default-locale}
* config property should be used.
*/
String DEFAULT_LOCALE = "<<default locale>>";

Expand Down
Loading

0 comments on commit fb88740

Please sign in to comment.