diff --git a/README.md b/README.md index c3d7dbe..c9c8602 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,73 @@ If *parameter* evaluates to false as either a *variable* or *condition*, the exp ``` +### Internationalisation + +#### In A Template + +A special form of Variable Expansion is used for internationalisation in a template. This supports arguments, as well as nested variables as arguments. + +You must still set a `ResourceBundle` on the model for I18n keys in a template to work. + +```java +var model = TemplateModel.ofContent( + """ +

${%someKey}

+ """). + bundle(MyClass.class); +``` + +And `MyClass.properties` .. + +``` +someKey=Some internationalised text +``` + +##### Basic + +The simplest syntax is `${%someKey}`, which will replace `someKey` with whatever the value is in the supplied `RessourceBundle`. + +##### With Arguments + +To supply arguments, a comma separated list is used. A comma is used by default, but any separator may be configured, and the separator may be escaped using a backslash `\`. + +For example `${%someKey arg0,arg 1 with space,arg 2 \, with comma}` + +##### With Nested Variables + +An argument can also be a nested variable that is available in the same scope as the replacement. + +Fixed text arguments and variables can be mixed in an I18N expression. However, you *cannot* currently mix text and variables in the same argument, for example `${%someKey prefix${var}suffix}` will *not* work. + +For example `${%someKey arg0,${var1},${var2}` + +#### In Code + +An alternative way to do parameterised i18n messages is in the model. This is particular useful when the calculation of the message is complex or inconvenient to express as either simple strings in the template, or as argument variables. + +In this case, you use the variable pattern `${someName}` instead of the I18n syntext with the `%` prefix. + +```java + +var model = TemplateModel.ofContent( + """ +

${someI18NVariable}

+ ${someOtherI18NVariable} + """). + i18n("someI18NVariable", "keyInBundle"). + i18n("someOtherI18NVariable", "keyInBundleWithArgs", "arg0", "arg1"). + bundle(MyClass.class); +``` + +And the bundle .. + +``` +keyInBundle=Some internationalised text +keyInBundleWithARgs=Some internationalised text {0} {1} +``` + +As with most `TemplateModel` attributes, you can defer calculation of the template text by supplying the appropriate `Supplier<..>` instead of a direct object reference. + ### Tags TinyTemplates primary use is with HTML and fragments of HTML. Tags by default use an *XML* syntax so as to @@ -343,7 +410,7 @@ var model = TemplateModel.ofContent(html). ``` -Lists make some default variables to each row. +Lists make some default variables available to each row. * `_size`, the size of the list. * `_index`, the zero-based index of the current row. diff --git a/src/main/java/com/sshtools/tinytemplate/Templates.java b/src/main/java/com/sshtools/tinytemplate/Templates.java index fdcd0a9..d667a20 100644 --- a/src/main/java/com/sshtools/tinytemplate/Templates.java +++ b/src/main/java/com/sshtools/tinytemplate/Templates.java @@ -53,6 +53,8 @@ public class Templates { + private final static List EMPTY_ARGS = Collections.emptyList(); + @FunctionalInterface public interface VariableStore extends Function { default boolean contains(String key) { @@ -69,6 +71,12 @@ public final static class Builder { private Function conditionEvaluator; private Optional logger = Optional.of(defaultStdOutLogger()); private Function variableSupplier; + private char argumentSeparator = ','; + + public Builder withArgumentSeparator(char argumentSeparator) { + this.argumentSeparator = argumentSeparator; + return this; + } public Builder withNullsAsNull() { return withNullsAreEmpty(false); @@ -161,12 +169,14 @@ private boolean eval(Map map, String k) { private final Function conditionEvaluator; private final Pattern varPattern; private final Pattern ternPattern; + private final char argumentSeparator; private VariableExpander(Builder bldr) { exprPattern = Pattern.compile(REGEXP); varPattern = Pattern.compile(VAR_REGEXP); ternPattern = Pattern.compile(TERN_REGEXP); + this.argumentSeparator = bldr.argumentSeparator; this.bundles = Collections.unmodifiableSet(new LinkedHashSet<>(bldr.bundles)); this.logger = bldr.logger; this.conditionEvaluator = bldr.conditionEvaluator; @@ -200,9 +210,25 @@ public String expand(String input) { if (intro.equals("%")) { /* i18n */ var word = mtchr.group(2); + var idx = input.indexOf(' '); + var args = idx == -1 ? new String[0] : splitArguments(input.substring(idx + 1)); + if(args.length > 0) { + for(int i = 0 ; i < args.length ; i++) { + try { + args[i] = expand(args[i]); + } + catch(IllegalArgumentException iae) { + args[i] = args[i]; + } + } + } for (var bundle : bundles) { try { - return bundle.get().getString(word); + if(args.length == 0) + return bundle.get().getString(word); + else { + return MessageFormat.format(bundle.get().getString(word), (Object[])args); + } } catch (MissingResourceException mre) { } } @@ -284,6 +310,29 @@ public String expand(String input) { return input; } + private String[] splitArguments(String input) { + var esc = false; + var l = new ArrayList(); + var b = new StringBuilder(); + for(var ch : input.toCharArray()) { + if(ch == '\\' && !esc) { + esc = true; + } + else if(ch == argumentSeparator && !esc) { + l.add(b.toString()); + b.setLength(0); + } + else { + b.append(ch); + esc = false; + } + } + if(b.length() > 0) { + l.add(b.toString()); + } + return l.toArray(new String[0]); + } + private Object supplyVal(String param) { var val = variableSupplier.apply(param); if (missingThrowsException && val == null) @@ -779,6 +828,7 @@ private final static class Block { boolean capture = false; int nestDepth = 0; String var; + int braceDepth = 0; Block(Block parent, TemplateModel model, VariableExpander expander, Reader reader/* , String scope */) { this(parent, model, expander, reader, null, true); @@ -892,19 +942,28 @@ private void read(Block block) { case VAR_START: if (process && ch == '{') { block.state = State.VAR_BRACE; + block.braceDepth++; buf.append(ch); } else { flushBuf(ch, buf, block); } break; case VAR_BRACE: - // TODO nested variables if (!esc && ch == '}') { - buf.append(ch); - expandToBuffer(block, buf); - buf.setLength(0); - block.state = State.START; + if(block.braceDepth == 1) { + buf.append(ch); + expandToBuffer(block, buf); + buf.setLength(0); + block.state = State.START; + } + else { + buf.append(ch); + } + block.braceDepth--; } else { + if(!esc && ch == '{') { + block.braceDepth++; + } buf.append(ch); } break; @@ -1089,9 +1148,11 @@ else if (process) { private void expandToBuffer(Block block, StringBuilder buf) { IllegalArgumentException exception = null; StringBuilder oblock = block.out; + var varStr = buf.toString(); + varStr = varStr.substring(2, varStr.length() - 1); while(block != null) { try { - oblock.append(block.expander.process(buf.toString())); + oblock.append(block.expander.expand(varStr)); return; } catch(IllegalArgumentException iae) { diff --git a/src/test/java/com/sshtools/tinytemplate/TemplatesTest.java b/src/test/java/com/sshtools/tinytemplate/TemplatesTest.java index 014d5d2..3b1f8be 100644 --- a/src/test/java/com/sshtools/tinytemplate/TemplatesTest.java +++ b/src/test/java/com/sshtools/tinytemplate/TemplatesTest.java @@ -163,10 +163,13 @@ public void testExpandMissingThrows() { public void testExpandI18N() { var exp = new VariableExpander.Builder(). withBundles(ResourceBundle.getBundle(TemplatesTest.class.getName())). + fromSimpleMap(createVars()). build(); assertEquals("Some localised key", exp.expand("%someKey")); assertEquals("%someMissingKey", exp.expand("%someMissingKey")); + assertEquals("Some key with args arg0 arg1 arg2", exp.expand("%someKeyWithArgs arg0,arg1,arg2")); + assertEquals("Some key with args 27 arg1 Some name", exp.expand("%someKeyWithArgs ${AGE},arg1,${NAME}")); } @Test @@ -1353,7 +1356,70 @@ public void testTemplateObjectInCondition() { } @Test - public void testTemplateFlaseConditionInObjectInTrueCondition() { + public void testTemplateI18N() { + Assertions.assertEquals(""" +

Some text

+

Some localised key

+

Some other text

+ """, + createParser().process(TemplateModel.ofContent(""" +

Some text

+

${%someKey}

+

Some other text

+ """).bundle(TemplatesTest.class) + )); + + } + + @Test + public void testTemplateI18NArgs() { + Assertions.assertEquals(""" +

Some text

+

Some key with args arg0 arg1 arg2

+

Some other text

+ """, + createParser().process(TemplateModel.ofContent(""" +

Some text

+

${%someKeyWithArgs arg0,arg1,arg2}

+

Some other text

+ """).bundle(TemplatesTest.class) + )); + } + + @Test + public void testTemplateI18NArgsAndEscapes() { + Assertions.assertEquals(""" +

Some text

+

Some key with args arg0,\\ arg1 arg2

+

Some other text

+ """, + createParser().process(TemplateModel.ofContent(""" +

Some text

+

${%someKeyWithArgs arg0\\\\,\\\\\\\\,arg1,arg2}

+

Some other text

+ """).bundle(TemplatesTest.class) + )); + } + + @Test + public void testTemplateI18NVars() { + Assertions.assertEquals(""" +

Some text

+

Some key with args arg0 VAL1 VAL2

+

Some other text

+ """, + createParser().process(TemplateModel.ofContent(""" +

Some text

+

${%someKeyWithArgs arg0,${VAR1},${VAR2}}

+

Some other text

+ """).bundle(TemplatesTest.class). + variable("VAR1", "VAL1"). + variable("VAR2", "VAL2") + )); + } + + @Test + public void testTemplateFalseConditionInObjectInTrueCondition() { Assertions.assertEquals("""

Some text

diff --git a/src/test/resources/com/sshtools/tinytemplate/TemplatesTest.properties b/src/test/resources/com/sshtools/tinytemplate/TemplatesTest.properties index 568cbf4..3490f17 100644 --- a/src/test/resources/com/sshtools/tinytemplate/TemplatesTest.properties +++ b/src/test/resources/com/sshtools/tinytemplate/TemplatesTest.properties @@ -1 +1,2 @@ -someKey=Some localised key \ No newline at end of file +someKey=Some localised key +someKeyWithArgs=Some key with args {0} {1} {2} \ No newline at end of file