diff --git a/docs/src/main/asciidoc/qute-reference.adoc b/docs/src/main/asciidoc/qute-reference.adoc
index 4b62a037bd432..f0f204fb9f4fa 100644
--- a/docs/src/main/asciidoc/qute-reference.adoc
+++ b/docs/src/main/asciidoc/qute-reference.adoc
@@ -457,7 +457,8 @@ First two elements: {#each myArray.take(2)}{it}{/each} <5>
==== Character Escapes
-For HTML and XML templates the `'`, `"`, `<`, `>`, `&` characters are escaped by default if a template variant is set.
+For HTML and XML templates the `'`, `"`, `<`, `>`, `&` characters are escaped by default if a corresponding template variant is set.
+For JSON templates the `"`, `\` and the control characters (`U+0000` through `U+001F`) are escaped by default if a corresponding template variant is set.
NOTE: In Quarkus, a variant is set automatically for templates located in the `src/main/resources/templates`. By default, the `java.net.URLConnection#getFileNameMap()` is used to determine the content-type of a template file. The additional map of suffixes to content types can be set via `quarkus.qute.content-types`.
@@ -466,6 +467,7 @@ If you need to render the unescaped value:
1. Either use the `raw` or `safe` properties implemented as extension methods of the `java.lang.Object`,
2. Or wrap the `String` value in a `io.quarkus.qute.RawString`.
+.HTML Example
[source,html]
----
@@ -478,6 +480,17 @@ If you need to render the unescaped value:
TIP: By default, a template with one of the following content types is escaped: `text/html`, `text/xml`, `application/xml` and `application/xhtml+xml`. However, it's possible to extend this list via the `quarkus.qute.escape-content-types` configuration property.
+.JSON Example
+[source,json]
+----
+{
+ "id": "{valueId.raw}", <1>
+ "name": "{valueName}" <2>
+}
+----
+<1> `valueId` that resolves to `\nA12345` will be rendered as `\nA12345` that will result in an invalid JSON Object because of the new line inserted inside the string value for the attribute `id`.
+<2> `valueName` that resolves to `\tExpressions \n Escapes` will be rendered as `\\tExpressions \\n Escapes`.
+
[[virtual_methods]]
==== Virtual Methods
diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/EscapingTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/EscapingTest.java
index a9d4564479892..4e6032fb159f9 100644
--- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/EscapingTest.java
+++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/EscapingTest.java
@@ -30,7 +30,9 @@ public class EscapingTest {
.addAsResource(new StringAsset("{text} {other} {text.raw} {text.safe} {item.foo}"),
"templates/bar.txt")
.addAsResource(new StringAsset("{@java.lang.String text}{text} {text.raw} {text.safe}"),
- "templates/validation.html"))
+ "templates/validation.html")
+ .addAsResource(new StringAsset("{ \"strVal\":\"{strVal}\", \"intVal\":{intVal} }"),
+ "templates/val.json"))
.overrideConfigKey("quarkus.qute.content-types.xhtml", "application/xhtml+xml")
.overrideConfigKey("quarkus.qute.suffixes", "qute.html,qute.txt,html,txt,xhtml");
@@ -58,6 +60,12 @@ public void testEscaper() {
item.data("item", new Item()).render());
}
+ @Test
+ public void testJsonEscaper() {
+ assertEquals("{ \"strVal\":\"\\t Foo \\u000b\", \"intVal\":42 }",
+ engine.getTemplate("val.json").data("strVal", "\t Foo \u000B").data("intVal", 42).render());
+ }
+
@Test
public void testValidation() {
assertEquals("<div>
",
diff --git a/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/EngineProducer.java b/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/EngineProducer.java
index cce813dff6155..aeba46a8cd3d8 100644
--- a/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/EngineProducer.java
+++ b/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/EngineProducer.java
@@ -40,6 +40,7 @@
import io.quarkus.qute.EvalContext;
import io.quarkus.qute.Expression;
import io.quarkus.qute.HtmlEscaper;
+import io.quarkus.qute.JsonEscaper;
import io.quarkus.qute.NamespaceResolver;
import io.quarkus.qute.ParserHook;
import io.quarkus.qute.Qute;
@@ -157,6 +158,9 @@ public EngineProducer(QuteContext context, QuteConfig config, QuteRuntimeConfig
// Escape some characters for HTML/XML templates
builder.addResultMapper(new HtmlEscaper(List.copyOf(config.escapeContentTypes)));
+ // Escape some characters for JSON templates
+ builder.addResultMapper(new JsonEscaper());
+
// Fallback reflection resolver
builder.addValueResolver(new ReflectionValueResolver());
diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/CharReplacementResultMapper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/CharReplacementResultMapper.java
new file mode 100644
index 0000000000000..04499e72a8da5
--- /dev/null
+++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/CharReplacementResultMapper.java
@@ -0,0 +1,45 @@
+package io.quarkus.qute;
+
+/**
+ * Makes it possible to replace chars from Basic Multilingual Plane (BMP).
+ *
+ * @see Character#isBmpCodePoint(int)
+ */
+abstract class CharReplacementResultMapper implements ResultMapper {
+
+ @Override
+ public String map(Object result, Expression expression) {
+ return escape(result.toString());
+ }
+
+ String escape(CharSequence value) {
+ if (value.length() == 0) {
+ return value.toString();
+ }
+ for (int i = 0; i < value.length(); i++) {
+ String replacement = replacementFor(value.charAt(i));
+ if (replacement != null) {
+ // In most cases we will not need to escape the value at all
+ return doEscape(value, i, new StringBuilder(value.subSequence(0, i)).append(replacement));
+ }
+ }
+ return value.toString();
+ }
+
+ private String doEscape(CharSequence value, int index, StringBuilder builder) {
+ int length = value.length();
+ while (++index < length) {
+ char c = value.charAt(index);
+ String replacement = replacementFor(c);
+ if (replacement != null) {
+ builder.append(replacement);
+ } else {
+ builder.append(c);
+ }
+ }
+ return builder.toString();
+ }
+
+ protected abstract String replacementFor(char c);
+
+}
diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/HtmlEscaper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/HtmlEscaper.java
index 194720231f85e..ff21a0742cf7a 100644
--- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/HtmlEscaper.java
+++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/HtmlEscaper.java
@@ -1,12 +1,11 @@
package io.quarkus.qute;
import java.util.List;
-import java.util.Objects;
import java.util.Optional;
import io.quarkus.qute.TemplateNode.Origin;
-public class HtmlEscaper implements ResultMapper {
+public class HtmlEscaper extends CharReplacementResultMapper {
private final List escapedContentTypes;
@@ -26,25 +25,6 @@ public boolean appliesTo(Origin origin, Object result) {
return false;
}
- @Override
- public String map(Object result, Expression expression) {
- return escape(result.toString());
- }
-
- String escape(CharSequence value) {
- if (Objects.requireNonNull(value).length() == 0) {
- return value.toString();
- }
- for (int i = 0; i < value.length(); i++) {
- String replacement = replacementFor(value.charAt(i));
- if (replacement != null) {
- // In most cases we will not need to escape the value at all
- return doEscape(value, i, new StringBuilder(value.subSequence(0, i)).append(replacement));
- }
- }
- return value.toString();
- }
-
private boolean requiresDefaultEscaping(Variant variant) {
String contentType = variant.getContentType();
if (contentType == null) {
@@ -58,21 +38,7 @@ private boolean requiresDefaultEscaping(Variant variant) {
return false;
}
- private String doEscape(CharSequence value, int index, StringBuilder builder) {
- int length = value.length();
- while (++index < length) {
- char c = value.charAt(index);
- String replacement = replacementFor(c);
- if (replacement != null) {
- builder.append(replacement);
- } else {
- builder.append(c);
- }
- }
- return builder.toString();
- }
-
- private String replacementFor(char c) {
+ protected String replacementFor(char c) {
switch (c) {
case '"':
return """;
diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/JsonEscaper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/JsonEscaper.java
new file mode 100644
index 0000000000000..662adc30c8797
--- /dev/null
+++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/JsonEscaper.java
@@ -0,0 +1,50 @@
+package io.quarkus.qute;
+
+import java.util.Optional;
+
+import io.quarkus.qute.TemplateNode.Origin;
+
+public class JsonEscaper extends CharReplacementResultMapper {
+
+ @Override
+ public boolean appliesTo(Origin origin, Object result) {
+ if (result instanceof RawString) {
+ return false;
+ }
+ Optional variant = origin.getVariant();
+ if (variant.isPresent()) {
+ String contentType = variant.get().getContentType();
+ if (contentType != null) {
+ return contentType.startsWith(Variant.APPLICATION_JSON);
+ }
+ }
+ return false;
+ }
+
+ protected String replacementFor(char c) {
+ // All Unicode characters may be placed within the quotation marks,
+ // except for the characters that MUST be escaped: quotation mark,
+ // reverse solidus, and the control characters (U+0000 through U+001F).
+ // See also https://datatracker.ietf.org/doc/html/rfc8259#autoid-10
+ switch (c) {
+ case '"':
+ return "\\\"";
+ case '\\':
+ return "\\\\";
+ case '\r':
+ return "\\r";
+ case '\b':
+ return "\\b";
+ case '\n':
+ return "\\n";
+ case '\t':
+ return "\\t";
+ case '\f':
+ return "\\f";
+ case '/':
+ return "\\/";
+ default:
+ return c < 32 ? String.format("\\u%04x", (int) c) : null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ResultMapper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ResultMapper.java
index fcefb57b6da42..55e803cb0f479 100644
--- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ResultMapper.java
+++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ResultMapper.java
@@ -25,7 +25,7 @@ default boolean appliesTo(Origin origin, Object result) {
/**
*
- * @param result
+ * @param result The result, never {@code null}
* @param expression The original expression
* @return the string value
*/
diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/JsonEscaperTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/JsonEscaperTest.java
new file mode 100644
index 0000000000000..e9fe26418efe6
--- /dev/null
+++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/JsonEscaperTest.java
@@ -0,0 +1,70 @@
+package io.quarkus.qute;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.qute.TemplateNode.Origin;
+
+public class JsonEscaperTest {
+
+ @Test
+ public void testAppliesTo() {
+ JsonEscaper json = new JsonEscaper();
+ Origin jsonOrigin = new Origin() {
+
+ @Override
+ public Optional getVariant() {
+ return Optional.of(Variant.forContentType(Variant.APPLICATION_JSON));
+ }
+
+ @Override
+ public String getTemplateId() {
+ return null;
+ }
+
+ @Override
+ public String getTemplateGeneratedId() {
+ return null;
+ }
+
+ @Override
+ public int getLineCharacterStart() {
+ return 0;
+ }
+
+ @Override
+ public int getLineCharacterEnd() {
+ return 0;
+ }
+
+ @Override
+ public int getLine() {
+ return 0;
+ }
+ };
+ assertFalse(json.appliesTo(jsonOrigin, new RawString("foo")));
+ assertTrue(json.appliesTo(jsonOrigin, "foo"));
+ }
+
+ @Test
+ public void testEscaping() throws IOException {
+ JsonEscaper json = new JsonEscaper();
+ assertEquals("Čolek 1", json.escape("Čolek 1"));
+ assertEquals("\\rČolek\\n", json.escape("\rČolek\n"));
+ assertEquals("\\tČolek", json.escape("\tČolek"));
+ assertEquals("\\\"tČolek", json.escape("\"tČolek"));
+ assertEquals("\\\\tČolek", json.escape("\\tČolek"));
+ assertEquals("\\\\u005C", json.escape("\\u005C"));
+ assertEquals("\\u000bČolek", json.escape("\u000BČolek"));
+ assertEquals("\\\\u000BČolek", json.escape("\\u000BČolek"));
+ // Control char - start of Header
+ assertEquals("\\u0001", json.escape("\u0001"));
+ }
+
+}