diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3ebb11..be1bc3b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@ All notable changes to `knotx-template-engine` will be documented in this file.
## Unreleased
List of changes that are finished but not yet released in any final version.
-
+- [PR-21](https://github.com/Knotx/knotx-template-engine/pull/21) - Pebble Template Engine
## 2.1.0
- [PR-18](https://github.com/Knotx/knotx-template-engine/pull/18) - `com.github.jknack` Handlebars updated to `4.1.2`.
diff --git a/README.md b/README.md
index 0feae9b..e9cd4f0 100644
--- a/README.md
+++ b/README.md
@@ -53,8 +53,8 @@ together with Transition.
## How to use
> Please note that example below uses Handlebars to process the markup. Read more about it below.
-> You may read about it in the [Handlebars module docs](https://github.com/Knotx/knotx-template-engine/tree/master/handlebars)
-> If you want to use another template engine, please refer to [core module docs](https://github.com/Knotx/knotx-template-engine/tree/master/core).
+> You may read about it in the [Handlebars module docs](https://github.com/Knotx/knotx-template-engine/tree/master/handlebars).
+> For using a different template engine, refer to [pebble module docs]() or [core module docs](https://github.com/Knotx/knotx-template-engine/tree/master/core).
Define a module that creates `io.knotx.te.core.TemplateEngineKnot` instance.
@@ -92,9 +92,10 @@ Now you may use it in Fragment's Tasks.
Example configuration is available in the [conf](https://github.com/Knotx/knotx-template-engine/blob/master/conf)
section of this module.
-## Handlebars Template Engine Strategy
-Currently this repository delivers `handlebars` TE strategy implementation.
-You may read more bout it in the [module docs](https://github.com/Knotx/knotx-template-engine/tree/master/handlebars).
+## OOTB Template Engine Strategies
+Currently this repository delivers `handlebars` and `pebble` TE strategies implementation.
+You may read more in the [handlebars](https://github.com/Knotx/knotx-template-engine/tree/master/handlebars) and [pebble](https://github.com/Knotx/knotx-template-engine/tree/master/pebble) module docs.
+
### How to create a custom Template Engine Strategy
Read more about it in the [API module docs](https://github.com/Knotx/knotx-template-engine/tree/master/api).
diff --git a/gradlew.bat b/gradlew.bat
index 24467a1..9618d8d 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -1,100 +1,100 @@
-@rem
-@rem Copyright 2015 the original author or authors.
-@rem
-@rem Licensed under the Apache License, Version 2.0 (the "License");
-@rem you may not use this file except in compliance with the License.
-@rem You may obtain a copy of the License at
-@rem
-@rem https://www.apache.org/licenses/LICENSE-2.0
-@rem
-@rem Unless required by applicable law or agreed to in writing, software
-@rem distributed under the License is distributed on an "AS IS" BASIS,
-@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-@rem See the License for the specific language governing permissions and
-@rem limitations under the License.
-@rem
-
-@if "%DEBUG%" == "" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto init
-
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto init
-
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:init
-@rem Get command-line arguments, handling Windows variants
-
-if not "%OS%" == "Windows_NT" goto win9xME_args
-
-:win9xME_args
-@rem Slurp the command line arguments.
-set CMD_LINE_ARGS=
-set _SKIP=2
-
-:win9xME_args_slurp
-if "x%~1" == "x" goto execute
-
-set CMD_LINE_ARGS=%*
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/pebble/README.md b/pebble/README.md
new file mode 100644
index 0000000..52a2396
--- /dev/null
+++ b/pebble/README.md
@@ -0,0 +1,40 @@
+# Knot.x Template Engine Pebble
+This section describes Pebble template engine strategy that is an alternative implementation of the
+template engine used in Knot.x examples.
+
+
+## How does it work
+Template Engine Pebble uses
+[Pebble Templates](https://pebbletemplates.io/) to compile and evaluate templates.
+Please refer to its documentation for any details.
+Additionally, Knot.x Template Engine Pebble has built-in Guava in-memory cache for compiled PEB
+Templates. Key computation is performed using the `cacheKeyAlgorithm` defined in the configuration
+(the default is `MD5` of the Fragment's `body`).
+
+## How to configure
+For all configuration fields and their defaults consult [io.knotx.te.pebble.options.PebbleEngineOptions](https://github.com/Knotx/knotx-template-engine/blob/master/pebble/docs/asciidoc/dataobjects.adoc)
+
+### Custom delimiters
+By default, the Pebble engine uses `{{` and `}}` symbols as print delimiters, `{%` and `%}` as computation delimiters, `{#` and `#}` as comment delimiters and `#{` and `}` as string interpolation delimiters.
+Knot.x Template Engine Pebble allows to change them using configuration
+
+E.g.:
+In order to use different symbols as below
+```html
+
+
Snippet1 - {: message :}
+
+```
+You can reconfigure an engine as follows in the pebble engine entry section:
+```hocon
+ {
+ factory = pebble
+ config = {
+ cacheSize = 1000
+ syntax = {
+ delimiterPrintOpen = "{:"
+ delimiterPrintClose = ":}"
+ }
+ }
+ }
+```
diff --git a/pebble/build.gradle.kts b/pebble/build.gradle.kts
new file mode 100644
index 0000000..10cd3f5
--- /dev/null
+++ b/pebble/build.gradle.kts
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2019 Knot.x Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import org.nosphere.apache.rat.RatTask
+
+plugins {
+ id("io.knotx.java-library")
+ id("io.knotx.unit-test")
+ id("io.knotx.maven-publish")
+ id("io.knotx.codegen")
+ id("io.knotx.jacoco")
+
+ id("org.nosphere.apache.rat") version "0.4.0"
+}
+
+dependencies {
+ implementation(platform("io.knotx:knotx-dependencies:${project.version}"))
+ implementation("io.knotx:knotx-commons:${project.version}")
+
+ api(project(":knotx-template-engine-api"))
+
+ implementation(group = "io.vertx", name = "vertx-core")
+ implementation(group = "io.vertx", name = "vertx-service-proxy")
+ implementation(group = "io.vertx", name = "vertx-rx-java2")
+ implementation(group = "com.google.guava", name = "guava")
+ implementation(group = "io.pebbletemplates", name= "pebble", version = "3.1.2")
+
+ testImplementation("io.knotx:knotx-junit5:${project.version}")
+ testImplementation("org.junit.jupiter:junit-jupiter-params")
+ testImplementation(group = "org.mockito", name = "mockito-core")
+ testImplementation(group = "org.mockito", name = "mockito-junit-jupiter")
+}
+
+tasks {
+ named("rat") {
+ excludes.addAll("*.md", "**/build/*", "**/out/*", "**/generated/*", "**/*.adoc", "**/resources/*", "gradle.properties")
+ }
+ getByName("build").dependsOn("rat")
+}
+
+publishing {
+ publications {
+ withType(MavenPublication::class) {
+ from(components["java"])
+ artifact(tasks["sourcesJar"])
+ artifact(tasks["javadocJar"])
+ }
+ }
+}
diff --git a/pebble/docs/asciidoc/dataobjects.adoc b/pebble/docs/asciidoc/dataobjects.adoc
new file mode 100644
index 0000000..c202cba
--- /dev/null
+++ b/pebble/docs/asciidoc/dataobjects.adoc
@@ -0,0 +1,80 @@
+= Cheatsheets
+
+[[PebbleEngineOptions]]
+== PebbleEngineOptions
+
+++++
+ Describes Pebble Knot configuration. Contains cache settings and Pebble Engine settings.
+++++
+'''
+
+[cols=">25%,25%,50%"]
+[frame="topbot"]
+|===
+^|Name | Type ^| Description
+|[[cacheKeyAlgorithm]]`@cacheKeyAlgorithm`|`String`|+++
+Sets the algorithm used to build a hash from the Pebble snippet. The hash is to be used as a
+ cache key.
+
+ The name should be a standard Java Security name (such as "SHA", "MD5", and so on).
++++
+|[[cacheSize]]`@cacheSize`|`Number (Long)`|+++
+Sets the size of the cache. After reaching the max size, new elements will replace the oldest
+ one.
++++
+|[[syntax]]`@syntax`|`link:dataobjects.html#PebbleEngineSyntaxOptions[PebbleEngineSyntaxOptions]`|+++
+Sets syntax options including custom Pebble markers' delimiters.
++++
+|===
+
+[[PebbleEngineSyntaxOptions]]
+== PebbleEngineSyntaxOptions
+
+++++
+ Describes custom syntax for the Pebble Engine.
+++++
+'''
+
+[cols=">25%,25%,50%"]
+[frame="topbot"]
+|===
+^|Name | Type ^| Description
+|[[delimiterCommentClose]]`@delimiterCommentClose`|`String`|+++
+Defaults to #}
++++
+|[[delimiterCommentOpen]]`@delimiterCommentOpen`|`String`|+++
+Defaults to {#
++++
+|[[delimiterExecuteClose]]`@delimiterExecuteClose`|`String`|+++
+Defaults to %}
++++
+|[[delimiterExecuteOpen]]`@delimiterExecuteOpen`|`String`|+++
+Defaults to {%
++++
+|[[delimiterPrintClose]]`@delimiterPrintClose`|`String`|+++
+Defaults to }}
++++
+|[[delimiterPrintOpen]]`@delimiterPrintOpen`|`String`|+++
+Defaults to {{
++++
+|[[literalDecimalTreatedAsInteger]]`@literalDecimalTreatedAsInteger`|`Boolean`|+++
+Sets whether a literal decimal should be treated as Integer.
If false, all literal
+ decimals will be treated as Long.
A literal decimal is considered a numeric string without
+ a fraction separator (a dot).
Defaults to false.
++++
+|[[newLineTrimming]]`@newLineTrimming`|`Boolean`|+++
+Sets whether a new line after a closing Pebble tag should be trimmed. Defaults to true.
++++
+|[[strictVariables]]`@strictVariables`|`Boolean`|+++
+Sets the Strict Mode of the Pebble Engine.
In Strict Mode, referencing a null or
+ non-existent variable or attribute will result in an exception.
When Strict Mode is
+ disabled, such variables/attributes will be rendered as empty.
+
+ Strict mode is disabled by default.
++++
+|[[whitespaceTrim]]`@whitespaceTrim`|`String`|+++
+Defaults to -
Note: this is used for trimming whitespaces adjacent to a Pebble
+ tag, not inside the content rendered by it.
++++
+|===
+
diff --git a/pebble/gradle.properties b/pebble/gradle.properties
new file mode 100644
index 0000000..70b8eae
--- /dev/null
+++ b/pebble/gradle.properties
@@ -0,0 +1,3 @@
+artifactId=knotx-template-engine-pebble
+publication.name=Knot.x Template Engine Pebble
+publication.description=Knot.x Template Engine Pebble
diff --git a/pebble/src/main/java/io/knotx/te/pebble/PebbleEngineSyntaxComposer.java b/pebble/src/main/java/io/knotx/te/pebble/PebbleEngineSyntaxComposer.java
new file mode 100644
index 0000000..44f297a
--- /dev/null
+++ b/pebble/src/main/java/io/knotx/te/pebble/PebbleEngineSyntaxComposer.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2019 Knot.x Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.knotx.te.pebble;
+
+import com.mitchellbosecke.pebble.lexer.Syntax;
+import io.knotx.te.pebble.options.PebbleEngineSyntaxOptions;
+
+final class PebbleEngineSyntaxComposer {
+
+ private PebbleEngineSyntaxComposer() {
+ // utility class
+ }
+
+ static Syntax compose(PebbleEngineSyntaxOptions options) {
+ return new Syntax.Builder()
+ .setCommentOpenDelimiter(options.getDelimiterCommentOpen())
+ .setCommentCloseDelimiter(options.getDelimiterCommentClose())
+ .setExecuteOpenDelimiter(options.getDelimiterExecuteOpen())
+ .setExecuteCloseDelimiter(options.getDelimiterExecuteClose())
+ .setPrintOpenDelimiter(options.getDelimiterPrintOpen())
+ .setPrintCloseDelimiter(options.getDelimiterPrintClose())
+ .setWhitespaceTrim(options.getWhitespaceTrim())
+ .build();
+ }
+
+}
diff --git a/pebble/src/main/java/io/knotx/te/pebble/PebbleTemplateEngine.java b/pebble/src/main/java/io/knotx/te/pebble/PebbleTemplateEngine.java
new file mode 100644
index 0000000..1cc07bb
--- /dev/null
+++ b/pebble/src/main/java/io/knotx/te/pebble/PebbleTemplateEngine.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2019 Knot.x Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.knotx.te.pebble;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.mitchellbosecke.pebble.PebbleEngine;
+import com.mitchellbosecke.pebble.loader.StringLoader;
+import com.mitchellbosecke.pebble.template.PebbleTemplate;
+import io.knotx.commons.json.JsonConverter;
+import io.knotx.fragments.api.Fragment;
+import io.knotx.te.api.TemplateEngine;
+import io.knotx.te.pebble.options.PebbleEngineOptions;
+import io.knotx.te.pebble.options.PebbleEngineSyntaxOptions;
+import io.vertx.core.logging.Logger;
+import io.vertx.core.logging.LoggerFactory;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * This class is registered with Service Provider Interface (see META-INF/services)
+ */
+class PebbleTemplateEngine implements TemplateEngine {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PebbleTemplateEngine.class);
+
+ private final PebbleEngine pebbleEngine;
+ private final Cache cache;
+ private final MessageDigest digest;
+
+ PebbleTemplateEngine(PebbleEngineOptions options) {
+ LOGGER.info("<{}> instance created", this.getClass().getSimpleName());
+ this.pebbleEngine = createPebbleEngine(options.getSyntax());
+ this.cache = createCache(options);
+ this.digest = tryToCreateDigest(options);
+ }
+
+ @Override
+ public String process(Fragment fragment) {
+ PebbleTemplate template = getTemplate(fragment);
+ traceProcessingFragment(fragment);
+ return tryToProcessOnEngine(template, fragment);
+ }
+
+ private PebbleTemplate getTemplate(Fragment fragment) {
+ try {
+ String cacheKey = getCacheKey(fragment);
+ return cache.get(cacheKey, () -> {
+ traceCompilingFragment(fragment);
+ return pebbleEngine.getTemplate(fragment.getBody());
+ });
+ } catch (ExecutionException e) {
+ LOGGER.error("Could not compile fragment [{}]", fragment.abbreviate(), e);
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private String tryToProcessOnEngine(PebbleTemplate template, Fragment fragment) {
+ try {
+ Map context = JsonConverter.plainMapFrom(fragment.getPayload());
+ StringWriter writer = new StringWriter();
+ template.evaluate(writer, context);
+ return writer.toString();
+ } catch (IOException e) {
+ LOGGER.error("Could not apply context to fragment [{}]", fragment.abbreviate(), e);
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private String getCacheKey(Fragment fragment) {
+ byte[] cacheKeyBytes = digest.digest(fragment.getBody().getBytes(StandardCharsets.UTF_8));
+ return new String(cacheKeyBytes);
+ }
+
+ private PebbleEngine createPebbleEngine(PebbleEngineSyntaxOptions syntaxOptions) {
+ return new PebbleEngine.Builder()
+ .loader(new StringLoader())
+ .cacheActive(false)
+ .strictVariables(syntaxOptions.isStrictVariables())
+ .newLineTrimming(syntaxOptions.isNewLineTrimming())
+ .syntax(PebbleEngineSyntaxComposer.compose(syntaxOptions))
+ .literalDecimalTreatedAsInteger(syntaxOptions.isLiteralDecimalTreatedAsInteger())
+ .build();
+ }
+
+ private Cache createCache(PebbleEngineOptions options) {
+ return CacheBuilder.newBuilder()
+ .maximumSize(options.getCacheSize())
+ .removalListener(listener -> LOGGER.warn(
+ "Cache limit exceeded. Revisit 'cacheSize' setting"))
+ .build();
+ }
+
+ private MessageDigest tryToCreateDigest(PebbleEngineOptions options) {
+ try {
+ return MessageDigest.getInstance(options.getCacheKeyAlgorithm());
+ } catch (NoSuchAlgorithmException e) {
+ LOGGER.error("No such algorithm available {}.", options.getCacheKeyAlgorithm(), e);
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ private static void traceProcessingFragment(Fragment fragment) {
+ if (LOGGER.isTraceEnabled()) {
+ LOGGER.trace("Processing with Pebble: {}!", fragment);
+ }
+ }
+
+ private static void traceCompilingFragment(Fragment fragment) {
+ if (LOGGER.isTraceEnabled()) {
+ LOGGER.trace("Compiled Pebble fragment [{}]", fragment);
+ }
+ }
+
+}
diff --git a/pebble/src/main/java/io/knotx/te/pebble/PebbleTemplateEngineFactory.java b/pebble/src/main/java/io/knotx/te/pebble/PebbleTemplateEngineFactory.java
new file mode 100644
index 0000000..36872b8
--- /dev/null
+++ b/pebble/src/main/java/io/knotx/te/pebble/PebbleTemplateEngineFactory.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2019 Knot.x Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.knotx.te.pebble;
+
+import io.knotx.te.api.TemplateEngine;
+import io.knotx.te.api.TemplateEngineFactory;
+import io.knotx.te.pebble.options.PebbleEngineOptions;
+import io.vertx.core.json.JsonObject;
+import io.vertx.reactivex.core.Vertx;
+
+public class PebbleTemplateEngineFactory implements TemplateEngineFactory {
+
+ @Override
+ public String getName() {
+ return "pebble";
+ }
+
+ @Override
+ public TemplateEngine create(Vertx vertx, JsonObject config) {
+ return new PebbleTemplateEngine(new PebbleEngineOptions(config));
+ }
+}
diff --git a/pebble/src/main/java/io/knotx/te/pebble/options/PebbleEngineOptions.java b/pebble/src/main/java/io/knotx/te/pebble/options/PebbleEngineOptions.java
new file mode 100644
index 0000000..d3ab01e
--- /dev/null
+++ b/pebble/src/main/java/io/knotx/te/pebble/options/PebbleEngineOptions.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2019 Knot.x Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.knotx.te.pebble.options;
+
+import io.vertx.codegen.annotations.DataObject;
+import io.vertx.core.json.JsonObject;
+
+/**
+ * Describes Pebble Knot configuration. Contains cache settings and Pebble Engine settings.
+ */
+@DataObject(generateConverter = true, publicConverter = false)
+public class PebbleEngineOptions {
+
+ private String cacheKeyAlgorithm = "MD5";
+ private Long cacheSize;
+ private PebbleEngineSyntaxOptions syntax = new PebbleEngineSyntaxOptions();
+
+ public PebbleEngineOptions() {
+ }
+
+ public PebbleEngineOptions(JsonObject json) {
+ PebbleEngineOptionsConverter.fromJson(json, this);
+ }
+
+ public JsonObject toJson() {
+ JsonObject json = new JsonObject();
+ PebbleEngineOptionsConverter.toJson(this, json);
+ return json;
+ }
+
+ /**
+ * @return size of the cache
+ */
+ public Long getCacheSize() {
+ return cacheSize;
+ }
+
+ /**
+ * Sets the size of the cache. After reaching the max size, new elements will replace the oldest
+ * one.
+ *
+ * @param cacheSize size of the cache
+ * @return a reference to this, so the API can be used fluently
+ */
+ public PebbleEngineOptions setCacheSize(Long cacheSize) {
+ this.cacheSize = cacheSize;
+ return this;
+ }
+
+ /**
+ * @return name of the algorithm used to generate hash from the Pebble snippet
+ */
+ public String getCacheKeyAlgorithm() {
+ return cacheKeyAlgorithm;
+ }
+
+ /**
+ * Sets the algorithm used to build a hash from the Pebble snippet. The hash is to be used as a
+ * cache key.
+ *
+ * The name should be a standard Java Security name (such as "SHA", "MD5", and so on).
+ *
+ * @param cacheKeyAlgorithm algorithm name
+ * @return a reference to this, so the API can be used fluently
+ */
+ public PebbleEngineOptions setCacheKeyAlgorithm(String cacheKeyAlgorithm) {
+ this.cacheKeyAlgorithm = cacheKeyAlgorithm;
+ return this;
+ }
+
+ /**
+ * @return syntax options with custom delimiters
+ */
+ public PebbleEngineSyntaxOptions getSyntax() {
+ return syntax;
+ }
+
+ /**
+ * Sets syntax options including custom Pebble markers' delimiters.
+ *
+ * @param syntax the syntax options to be passed to the Pebble Engine
+ */
+ public void setSyntax(
+ PebbleEngineSyntaxOptions syntax) {
+ this.syntax = syntax;
+ }
+
+ @Override
+ public String toString() {
+ return "PebbleEngineOptions{" +
+ "cacheKeyAlgorithm='" + cacheKeyAlgorithm + '\'' +
+ ", cacheSize=" + cacheSize +
+ ", syntax=" + syntax +
+ '}';
+ }
+}
diff --git a/pebble/src/main/java/io/knotx/te/pebble/options/PebbleEngineSyntaxOptions.java b/pebble/src/main/java/io/knotx/te/pebble/options/PebbleEngineSyntaxOptions.java
new file mode 100644
index 0000000..94d5732
--- /dev/null
+++ b/pebble/src/main/java/io/knotx/te/pebble/options/PebbleEngineSyntaxOptions.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright (C) 2019 Knot.x Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.knotx.te.pebble.options;
+
+import io.vertx.codegen.annotations.DataObject;
+import io.vertx.core.json.JsonObject;
+
+/**
+ * Describes custom syntax for the Pebble Engine.
+ *
+ * @see Pebble Basic Usage
+ * @see Pebble Engine Settings in
+ * Installation guide
+ */
+@DataObject(generateConverter = true, publicConverter = false)
+public class PebbleEngineSyntaxOptions {
+
+ private boolean strictVariables = false;
+ private boolean newLineTrimming = true;
+ private boolean literalDecimalTreatedAsInteger = false;
+
+ private String delimiterCommentOpen = "{#";
+ private String delimiterCommentClose = "#}";
+ private String delimiterExecuteOpen = "{%";
+ private String delimiterExecuteClose = "%}";
+ private String delimiterPrintOpen = "{{";
+ private String delimiterPrintClose = "}}";
+ private String whitespaceTrim = "-";
+
+ public PebbleEngineSyntaxOptions() {
+ }
+
+ public PebbleEngineSyntaxOptions(JsonObject json) {
+ PebbleEngineSyntaxOptionsConverter.fromJson(json, this);
+ }
+
+ public JsonObject toJson() {
+ JsonObject json = new JsonObject();
+ PebbleEngineSyntaxOptionsConverter.toJson(this, json);
+ return json;
+ }
+
+ /**
+ * @return flag indicating whether strict mode should be enabled
+ */
+ public boolean isStrictVariables() {
+ return strictVariables;
+ }
+
+ /**
+ * Sets the Strict Mode of the Pebble Engine.
In Strict Mode, referencing a null or
+ * non-existent variable or attribute will result in an exception.
When Strict Mode is
+ * disabled, such variables/attributes will be rendered as empty.
+ *
+ * Strict mode is disabled by default.
+ *
+ * @param strictVariables flag indicating whether strict mode should be enabled
+ */
+ public void setStrictVariables(boolean strictVariables) {
+ this.strictVariables = strictVariables;
+ }
+
+ /**
+ * @return flag indicating whether a new line character occurring after a closing Pebble tag
+ * should be trimmed
+ */
+ public boolean isNewLineTrimming() {
+ return newLineTrimming;
+ }
+
+ /**
+ * Sets whether a new line after a closing Pebble tag should be trimmed. Defaults to true.
+ *
+ * @param newLineTrimming flag indicating whether a new line character occurring after a closing
+ * Pebble tag should be trimmed
+ */
+ public void setNewLineTrimming(boolean newLineTrimming) {
+ this.newLineTrimming = newLineTrimming;
+ }
+
+ /**
+ * @return flag indicating whether a literal decimal should be treated as Integer or Long
+ */
+ public boolean isLiteralDecimalTreatedAsInteger() {
+ return literalDecimalTreatedAsInteger;
+ }
+
+ /**
+ * Sets whether a literal decimal should be treated as Integer.
If false, all literal
+ * decimals will be treated as Long.
A literal decimal is considered a numeric string without
+ * a fraction separator (a dot).
Defaults to false.
+ *
+ * @param literalDecimalTreatedAsInteger flag indicating whether a literal decimal should be
+ * treated as Integer or Long
+ */
+ public void setLiteralDecimalTreatedAsInteger(boolean literalDecimalTreatedAsInteger) {
+ this.literalDecimalTreatedAsInteger = literalDecimalTreatedAsInteger;
+ }
+
+ /**
+ * @return custom delimiter for comment start
+ */
+ public String getDelimiterCommentOpen() {
+ return delimiterCommentOpen;
+ }
+
+ /**
+ * Defaults to {#
+ *
+ * @param delimiterCommentOpen custom delimiter for comment start
+ */
+ public void setDelimiterCommentOpen(String delimiterCommentOpen) {
+ this.delimiterCommentOpen = delimiterCommentOpen;
+ }
+
+ /**
+ * @return custom delimiter for comment end
+ */
+ public String getDelimiterCommentClose() {
+ return delimiterCommentClose;
+ }
+
+ /**
+ * Defaults to #}
+ *
+ * @param delimiterCommentClose custom delimiter for comment end
+ */
+ public void setDelimiterCommentClose(String delimiterCommentClose) {
+ this.delimiterCommentClose = delimiterCommentClose;
+ }
+
+ /**
+ * @return custom delimiter for execution tag start
+ */
+ public String getDelimiterExecuteOpen() {
+ return delimiterExecuteOpen;
+ }
+
+ /**
+ * Defaults to {%
+ *
+ * @param delimiterExecuteOpen custom delimiter for execution tag start
+ */
+ public void setDelimiterExecuteOpen(String delimiterExecuteOpen) {
+ this.delimiterExecuteOpen = delimiterExecuteOpen;
+ }
+
+ /**
+ * @return custom delimiter for execution tag end
+ */
+ public String getDelimiterExecuteClose() {
+ return delimiterExecuteClose;
+ }
+
+ /**
+ * Defaults to %}
+ *
+ * @param delimiterExecuteClose custom delimiter for execution tag end
+ */
+ public void setDelimiterExecuteClose(String delimiterExecuteClose) {
+ this.delimiterExecuteClose = delimiterExecuteClose;
+ }
+
+ /**
+ * @return custom delimiter for print tag start
+ */
+ public String getDelimiterPrintOpen() {
+ return delimiterPrintOpen;
+ }
+
+ /**
+ * Defaults to {{
+ *
+ * @param delimiterPrintOpen custom delimiter for print tag start
+ */
+ public void setDelimiterPrintOpen(String delimiterPrintOpen) {
+ this.delimiterPrintOpen = delimiterPrintOpen;
+ }
+
+ /**
+ * @return custom delimiter for print tag end
+ */
+ public String getDelimiterPrintClose() {
+ return delimiterPrintClose;
+ }
+
+ /**
+ * Defaults to }}
+ *
+ * @param delimiterPrintClose custom delimiter for print tag end
+ */
+ public void setDelimiterPrintClose(String delimiterPrintClose) {
+ this.delimiterPrintClose = delimiterPrintClose;
+ }
+
+ /**
+ * @return custom character used to enable leading/trailing whitespaces trimming
+ */
+ public String getWhitespaceTrim() {
+ return whitespaceTrim;
+ }
+
+ /**
+ * Defaults to -
Note: this is used for trimming whitespaces adjacent to a Pebble
+ * tag, not inside the content rendered by it.
+ *
+ * @param whitespaceTrim custom character used to enable leading/trailing whitespaces trimming
+ */
+ public void setWhitespaceTrim(String whitespaceTrim) {
+ this.whitespaceTrim = whitespaceTrim;
+ }
+
+ @Override
+ public String toString() {
+ return "PebbleEngineSyntaxOptions{" +
+ "strictVariables=" + strictVariables +
+ ", newLineTrimming=" + newLineTrimming +
+ ", literalDecimalTreatedAsInteger=" + literalDecimalTreatedAsInteger +
+ ", delimiterCommentOpen='" + delimiterCommentOpen + '\'' +
+ ", delimiterCommentClose='" + delimiterCommentClose + '\'' +
+ ", delimiterExecuteOpen='" + delimiterExecuteOpen + '\'' +
+ ", delimiterExecuteClose='" + delimiterExecuteClose + '\'' +
+ ", delimiterPrintOpen='" + delimiterPrintOpen + '\'' +
+ ", delimiterPrintClose='" + delimiterPrintClose + '\'' +
+ ", whitespaceTrim='" + whitespaceTrim + '\'' +
+ '}';
+ }
+}
diff --git a/pebble/src/main/java/io/knotx/te/pebble/package-info.java b/pebble/src/main/java/io/knotx/te/pebble/package-info.java
new file mode 100644
index 0000000..572e1ca
--- /dev/null
+++ b/pebble/src/main/java/io/knotx/te/pebble/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 Knot.x Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@ModuleGen(name = "knotx-te-pebble", groupPackage = "io.knotx")
+package io.knotx.te.pebble;
+
+import io.vertx.codegen.annotations.ModuleGen;
diff --git a/pebble/src/main/resources/META-INF/services/io.knotx.te.api.TemplateEngineFactory b/pebble/src/main/resources/META-INF/services/io.knotx.te.api.TemplateEngineFactory
new file mode 100644
index 0000000..4461e89
--- /dev/null
+++ b/pebble/src/main/resources/META-INF/services/io.knotx.te.api.TemplateEngineFactory
@@ -0,0 +1,15 @@
+# Copyright (C) 2019 Knot.x Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+io.knotx.te.pebble.PebbleTemplateEngineFactory
diff --git a/pebble/src/test/java/io/knotx/te/pebble/PebbleTemplateEngineTest.java b/pebble/src/test/java/io/knotx/te/pebble/PebbleTemplateEngineTest.java
new file mode 100644
index 0000000..f198218
--- /dev/null
+++ b/pebble/src/test/java/io/knotx/te/pebble/PebbleTemplateEngineTest.java
@@ -0,0 +1,215 @@
+/*
+ * Copyright (C) 2019 Knot.x Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.knotx.te.pebble;
+
+import static io.knotx.junit5.assertions.KnotxAssertions.assertEqualsIgnoreWhitespace;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.mitchellbosecke.pebble.error.AttributeNotFoundException;
+import com.mitchellbosecke.pebble.error.ParserException;
+import com.mitchellbosecke.pebble.error.RootAttributeNotFoundException;
+import io.knotx.fragments.api.Fragment;
+import io.knotx.junit5.util.FileReader;
+import io.knotx.te.pebble.options.PebbleEngineOptions;
+import io.knotx.te.pebble.options.PebbleEngineSyntaxOptions;
+import io.vertx.core.json.JsonObject;
+import java.io.IOException;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class PebbleTemplateEngineTest {
+
+ private static final String TEMPLATE_EMPTY = "templates/empty.peb";
+ private static final String TEMPLATE_SAMPLE = "templates/sample.peb";
+ private static final String TEMPLATE_SERVICE = "templates/service.peb";
+ private static final String TEMPLATE_SERVICE_CUSTOM_SYNTAX = "templates/serviceCustomSyntax.peb";
+ private static final String TEMPLATE_UNDEFINED_HELPER = "templates/undefinedHelper.peb";
+
+ private static final String CONTEXT_EMPTY = "data/emptyContext.json";
+ private static final String CONTEXT_SAMPLE = "data/sampleContext.json";
+ private static final String CONTEXT_SAMPLE_MISSING_FIELD = "data/sampleContextMissingField.json";
+ private static final String CONTEXT_SERVICE = "data/serviceContext.json";
+
+ private static final String RESULT_EMPTY_CONTENT = "results/emptyContent";
+ private static final String RESULT_EMPTY_CONTEXT = "results/emptyContext";
+ private static final String RESULT_SAMPLE = "results/sample";
+ private static final String RESULT_SAMPLE_MISSING_FIELD = "results/sampleMissingField";
+ private static final String RESULT_SERVICE = "results/service";
+
+ private PebbleEngineOptions options;
+
+ private static Stream passingInDefaultMode() {
+ return Stream.of( // template, context, expectedResult
+ Arguments.of(TEMPLATE_EMPTY, CONTEXT_SAMPLE, RESULT_EMPTY_CONTENT),
+ Arguments.of(TEMPLATE_SAMPLE, CONTEXT_EMPTY, RESULT_EMPTY_CONTEXT),
+ Arguments.of(TEMPLATE_SAMPLE, CONTEXT_SAMPLE, RESULT_SAMPLE),
+ Arguments.of(TEMPLATE_SERVICE, CONTEXT_SERVICE, RESULT_SERVICE),
+ Arguments.of(TEMPLATE_SAMPLE, CONTEXT_SAMPLE_MISSING_FIELD, RESULT_SAMPLE_MISSING_FIELD)
+ );
+ }
+
+ private static Stream passingInStrictMode() {
+ return Stream.of( // template, context, expectedResult
+ Arguments.of(TEMPLATE_EMPTY, CONTEXT_SAMPLE, RESULT_EMPTY_CONTENT),
+ Arguments.of(TEMPLATE_SAMPLE, CONTEXT_SAMPLE, RESULT_SAMPLE),
+ Arguments.of(TEMPLATE_SERVICE, CONTEXT_SERVICE, RESULT_SERVICE)
+ );
+ }
+
+ private static Stream failingInDefaultMode() {
+ return Stream.of( // template, context, exception
+ Arguments.of(TEMPLATE_UNDEFINED_HELPER, CONTEXT_EMPTY, UncheckedExecutionException.class,
+ ParserException.class)
+ );
+ }
+
+ private static Stream failingInStrictMode() {
+ return Stream.of( // template, context, exception
+ Arguments.of(TEMPLATE_SAMPLE, CONTEXT_SAMPLE_MISSING_FIELD, AttributeNotFoundException.class, null),
+ Arguments.of(TEMPLATE_SAMPLE, CONTEXT_EMPTY, RootAttributeNotFoundException.class, null),
+ Arguments.of(TEMPLATE_UNDEFINED_HELPER, CONTEXT_EMPTY, UncheckedExecutionException.class,
+ ParserException.class)
+ );
+ }
+
+ @BeforeEach
+ void setUp() {
+ options = new PebbleEngineOptions().setCacheSize(100L);
+ }
+
+ @ParameterizedTest
+ @MethodSource("passingInDefaultMode")
+ @DisplayName("Expect successful template processing in default mode")
+ void renderTemplateInDefaultMode(String template, String context, String expectedResult)
+ throws IOException {
+ final PebbleTemplateEngine templateEngine = new PebbleTemplateEngine(options);
+
+ final Fragment fragment = mockFragmentFromFile(template, context);
+ final String result = templateEngine.process(fragment).trim();
+ final String expected = FileReader.readText(expectedResult).trim();
+
+ assertEqualsIgnoreWhitespace(expected, result);
+ }
+
+ @ParameterizedTest
+ @MethodSource("failingInDefaultMode")
+ @DisplayName("Expect exception to be thrown when template/context input is invalid in default mode")
+ void renderTemplateInDefaultMode(String template, String context,
+ Class expectedRootException, Class expectedNestedException)
+ throws IOException {
+ final PebbleTemplateEngine templateEngine = new PebbleTemplateEngine(options);
+
+ final Fragment fragment = mockFragmentFromFile(template, context);
+
+ Throwable exception = assertThrows(expectedRootException,
+ () -> templateEngine.process(fragment));
+
+ if (expectedNestedException != null) {
+ assertEquals(expectedNestedException, exception.getCause().getClass());
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("passingInStrictMode")
+ @DisplayName("Expect successful template processing in strict mode")
+ void renderTemplateInStrictMode(String template, String context, String expectedResult)
+ throws IOException {
+ options.getSyntax().setStrictVariables(true);
+ final PebbleTemplateEngine templateEngine = new PebbleTemplateEngine(options);
+
+ final Fragment fragment = mockFragmentFromFile(template, context);
+ final String result = templateEngine.process(fragment).trim();
+ final String expected = FileReader.readText(expectedResult).trim();
+
+ assertEqualsIgnoreWhitespace(expected, result);
+ }
+
+ @ParameterizedTest
+ @MethodSource("failingInStrictMode")
+ @DisplayName("Expect exception to be thrown when template/context input is invalid in strict mode")
+ void renderTemplateInStrictMode(String template, String context,
+ Class expectedRootException, Class expectedNestedException)
+ throws IOException {
+ options.getSyntax().setStrictVariables(true);
+ final PebbleTemplateEngine templateEngine = new PebbleTemplateEngine(options);
+
+ final Fragment fragment = mockFragmentFromFile(template, context);
+
+ Throwable exception = assertThrows(expectedRootException,
+ () -> templateEngine.process(fragment));
+
+ if (expectedNestedException != null) {
+ assertEquals(expectedNestedException, exception.getCause().getClass());
+ }
+ }
+
+ @Test
+ @DisplayName("Expect template with custom syntax to be filled properly in default mode")
+ void renderTemplateWithCustomDelimitersInDefaultMode() throws IOException {
+ options.setSyntax(getCustomSyntaxOptions());
+ final PebbleTemplateEngine templateEngine = new PebbleTemplateEngine(options);
+
+ final Fragment fragment = mockFragmentFromFile(TEMPLATE_SERVICE_CUSTOM_SYNTAX, CONTEXT_SERVICE);
+ final String result = templateEngine.process(fragment).trim();
+ final String expected = FileReader.readText(RESULT_SERVICE).trim();
+
+ assertEqualsIgnoreWhitespace(expected, result);
+ }
+
+ @Test
+ @DisplayName("Expect template with custom syntax to be filled properly in strict mode")
+ void renderTemplateWithCustomDelimitersInStrictMode() throws IOException {
+ PebbleEngineSyntaxOptions syntaxOptions = getCustomSyntaxOptions();
+ syntaxOptions.setStrictVariables(true);
+ options.setSyntax(syntaxOptions);
+ final PebbleTemplateEngine templateEngine = new PebbleTemplateEngine(options);
+
+ final Fragment fragment = mockFragmentFromFile(TEMPLATE_SERVICE_CUSTOM_SYNTAX, CONTEXT_SERVICE);
+ final String result = templateEngine.process(fragment).trim();
+ final String expected = FileReader.readText(RESULT_SERVICE).trim();
+
+ assertEqualsIgnoreWhitespace(expected, result);
+ }
+
+ private Fragment mockFragmentFromFile(String bodyFilePath, String contextFilePath)
+ throws IOException {
+ final String body = FileReader.readText(bodyFilePath).trim();
+ final String context = FileReader.readText(contextFilePath).trim();
+
+ Fragment fragment = new Fragment("snippet", new JsonObject(), body);
+ fragment.mergeInPayload(new JsonObject(context));
+ return fragment;
+ }
+
+ private PebbleEngineSyntaxOptions getCustomSyntaxOptions() {
+ PebbleEngineSyntaxOptions customSyntaxOptions = new PebbleEngineSyntaxOptions();
+ customSyntaxOptions.setDelimiterCommentOpen("/*");
+ customSyntaxOptions.setDelimiterCommentClose("*/");
+ customSyntaxOptions.setDelimiterExecuteOpen("<<");
+ customSyntaxOptions.setDelimiterExecuteClose(">>");
+ customSyntaxOptions.setDelimiterPrintOpen("<|");
+ customSyntaxOptions.setDelimiterPrintClose("|>");
+ return customSyntaxOptions;
+ }
+
+}
diff --git a/pebble/src/test/resources/data/emptyContext.json b/pebble/src/test/resources/data/emptyContext.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/pebble/src/test/resources/data/emptyContext.json
@@ -0,0 +1 @@
+{}
diff --git a/pebble/src/test/resources/data/sampleContext.json b/pebble/src/test/resources/data/sampleContext.json
new file mode 100644
index 0000000..ca9599d
--- /dev/null
+++ b/pebble/src/test/resources/data/sampleContext.json
@@ -0,0 +1,38 @@
+{
+ "sample": {
+ "result": {
+ "first": "First Message",
+ "second": {
+ "foo": "Very Long Second Foo Message !! 2 &&%^$"
+ }
+ },
+ "arr": [
+ 1,
+ 2,
+ 3,
+ "foo",
+ "bar",
+ true
+ ]
+ },
+ "items": [
+ {
+ "title": "First item",
+ "content": "First item's content",
+ "category": "Category 1"
+ },
+ {
+ "title": "Second item",
+ "content": "Second item's content",
+ "category": "Category 2"
+ },
+ {
+ "title": "Third item",
+ "content": "Third item's content",
+ "category": "Undefined"
+ },
+ {
+ "title": "Item without category or content"
+ }
+ ]
+}
diff --git a/pebble/src/test/resources/data/sampleContextMissingField.json b/pebble/src/test/resources/data/sampleContextMissingField.json
new file mode 100644
index 0000000..cc39c49
--- /dev/null
+++ b/pebble/src/test/resources/data/sampleContextMissingField.json
@@ -0,0 +1,15 @@
+{
+ "sample": {
+ "result": {
+ "first": "First Message"
+ },
+ "arr": [
+ 1,
+ 2,
+ 3,
+ "foo",
+ "bar",
+ true
+ ]
+ }
+}
diff --git a/pebble/src/test/resources/data/serviceContext.json b/pebble/src/test/resources/data/serviceContext.json
new file mode 100644
index 0000000..3ba5105
--- /dev/null
+++ b/pebble/src/test/resources/data/serviceContext.json
@@ -0,0 +1,24 @@
+{
+ "items": [
+ {
+ "swatches": [
+ {
+ "all_swatch_ids": "PX1|PC2|PG1",
+ "all_swatch_links": "http://my-website.com/products/1|http://my-website.com/products/2|http://my-website.com/products/3",
+ "all_swatch_images": "http://my-images.com/swatch1.jpg|http://my-images.com/swatch2.jpg|http://my-images.com/swatch3.jpg",
+ "all_swatch_names": "Product 1|Product 2|Product 3",
+ "description": "Sample product 1 description",
+ "title": "My product 1"
+ },
+ {
+ "all_swatch_ids": "A1|A2|A3",
+ "all_swatch_links": "http://my-website.com/products/1|http://my-website.com/products/2|http://my-website.com/products/3",
+ "all_swatch_images": "http://my-images.com/swatch1.jpg|http://my-images.com/swatch2.jpg|http://my-images.com/swatch3.jpg",
+ "all_swatch_names": "Product 1|Product 2|Product 3",
+ "description": "Sample product 2 description",
+ "title": "My product 2"
+ }
+ ]
+ }
+ ]
+}
diff --git a/pebble/src/test/resources/results/emptyContent b/pebble/src/test/resources/results/emptyContent
new file mode 100644
index 0000000..e69de29
diff --git a/pebble/src/test/resources/results/emptyContext b/pebble/src/test/resources/results/emptyContext
new file mode 100644
index 0000000..5ed7baf
--- /dev/null
+++ b/pebble/src/test/resources/results/emptyContext
@@ -0,0 +1,4 @@
+ First:
+ Second:
+ Array:
+ Interpolation & Upper Case filter: INTERPOLATED
diff --git a/pebble/src/test/resources/results/sample b/pebble/src/test/resources/results/sample
new file mode 100644
index 0000000..b8ddadd
--- /dev/null
+++ b/pebble/src/test/resources/results/sample
@@ -0,0 +1,4 @@
+ First: First Message
+ Second: Very Long Second Foo Message !! 2 &&%^$
+ Array: 1 2 3 foo bar true
+ Interpolation & Upper Case filter: INTERPOLATED FIRST MESSAGE
diff --git a/pebble/src/test/resources/results/sampleMissingField b/pebble/src/test/resources/results/sampleMissingField
new file mode 100644
index 0000000..154949f
--- /dev/null
+++ b/pebble/src/test/resources/results/sampleMissingField
@@ -0,0 +1,4 @@
+ First: First Message
+ Second:
+ Array: 1 2 3 foo bar true
+ Interpolation & Upper Case filter: INTERPOLATED FIRST MESSAGE
diff --git a/pebble/src/test/resources/results/service b/pebble/src/test/resources/results/service
new file mode 100644
index 0000000..7fb0782
--- /dev/null
+++ b/pebble/src/test/resources/results/service
@@ -0,0 +1,42 @@
+
diff --git a/pebble/src/test/resources/templates/empty.peb b/pebble/src/test/resources/templates/empty.peb
new file mode 100644
index 0000000..e69de29
diff --git a/pebble/src/test/resources/templates/sample.peb b/pebble/src/test/resources/templates/sample.peb
new file mode 100644
index 0000000..98e33f1
--- /dev/null
+++ b/pebble/src/test/resources/templates/sample.peb
@@ -0,0 +1,4 @@
+ First: {{ sample.result.first }}
+ Second: {{sample.result.second.foo }}
+ Array: {% for this in sample.arr %} {{this}} {% endfor %}
+ Interpolation & Upper Case filter: {{ "Interpolated #{ sample.result.first }" | upper }}
diff --git a/pebble/src/test/resources/templates/service.peb b/pebble/src/test/resources/templates/service.peb
new file mode 100644
index 0000000..38e2a9d
--- /dev/null
+++ b/pebble/src/test/resources/templates/service.peb
@@ -0,0 +1,21 @@
+{% for item in items %}
+
+ {% for swatch in item.swatches %}
+ {% set links = swatch.all_swatch_links | split('\|') %}
+ {% set ids = swatch.all_swatch_ids | split('\|') %}
+ {% set images = swatch.all_swatch_images | split('\|') %}
+
+ {% for link in links %}
+ -
+
{{ loop.index + 1 }}
+
+
+
+
+ {% endfor %}
+
+ {% endfor %}
+
+{% endfor %}
+
+{# This is a comment and will not be printed #}
diff --git a/pebble/src/test/resources/templates/serviceCustomSyntax.peb b/pebble/src/test/resources/templates/serviceCustomSyntax.peb
new file mode 100644
index 0000000..706cb2d
--- /dev/null
+++ b/pebble/src/test/resources/templates/serviceCustomSyntax.peb
@@ -0,0 +1,21 @@
+<< for item in items >>
+
+ << for swatch in item.swatches >>
+ << set links = swatch.all_swatch_links | split('\|') >>
+ << set ids = swatch.all_swatch_ids | split('\|') >>
+ << set images = swatch.all_swatch_images | split('\|') >>
+
+ << for link in links >>
+ -
+
<| loop.index + 1 |>
+
+
+
+
+ << endfor >>
+
+ << endfor >>
+
+<< endfor >>
+
+/* This is a comment and will not be printed */
diff --git a/pebble/src/test/resources/templates/undefinedHelper.peb b/pebble/src/test/resources/templates/undefinedHelper.peb
new file mode 100644
index 0000000..5cf870b
--- /dev/null
+++ b/pebble/src/test/resources/templates/undefinedHelper.peb
@@ -0,0 +1,3 @@
+ First: {{ sample.result.first}}
+ Second: {{sample.result.second.foo}}
+ Empty: {{#notDefinedSymbol}}This will cause exception{{/notDefinedSymbol}}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index dc60338..0ace288 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,8 +19,10 @@ include("knotx-template-engine-api")
include("knotx-template-engine-core")
include("knotx-template-engine-handlebars")
include("knotx-template-engine-it-test")
+include("knotx-template-engine-pebble")
project(":knotx-template-engine-api").projectDir = file("api")
project(":knotx-template-engine-core").projectDir = file("core")
project(":knotx-template-engine-handlebars").projectDir = file("handlebars")
+project(":knotx-template-engine-pebble").projectDir = file("pebble")
project(":knotx-template-engine-it-test").projectDir = file("it-test")