io.jooby
jooby-jackson
diff --git a/modules/jooby-freemarker/src/main/java/io/jooby/freemarker/FreemarkerModule.java b/modules/jooby-freemarker/src/main/java/io/jooby/freemarker/FreemarkerModule.java
index b1f33dfc4f..5adbf3b4d9 100644
--- a/modules/jooby-freemarker/src/main/java/io/jooby/freemarker/FreemarkerModule.java
+++ b/modules/jooby-freemarker/src/main/java/io/jooby/freemarker/FreemarkerModule.java
@@ -273,7 +273,8 @@ public void install(Jooby application) {
.setTemplatesPath(templatesPath)
.build(application.getEnvironment());
}
- application.encoder(new FreemarkerTemplateEngine(freemarker, EXT));
+ var templateEngine = new FreemarkerTemplateEngine(freemarker, EXT);
+ application.encoder(templateEngine);
var services = application.getServices();
services.put(Configuration.class, freemarker);
diff --git a/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java b/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java
index cd42ef3a80..16f1546cdc 100644
--- a/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java
+++ b/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java
@@ -7,6 +7,7 @@
import static java.util.Collections.singletonList;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -43,7 +44,7 @@
import io.jooby.*;
import io.jooby.test.MockContext;
-public class FreemarkerModuleTest {
+class FreemarkerModuleTest {
public static class MyModel {
public String firstname;
@@ -89,6 +90,7 @@ void setUp() {
when(app.getEnvironment()).thenReturn(env);
when(app.getServices()).thenReturn(registry);
+
when(env.getConfig()).thenReturn(config);
when(config.hasPath("freemarker")).thenReturn(false);
when(env.isActive("dev", "test")).thenReturn(false);
@@ -208,6 +210,14 @@ void testBuilderCacheStorageInProdMode() {
assertEquals("freemarker.cache.MruCacheStorage", conf.getCacheStorage().getClass().getName());
}
+ @Test
+ void testBuilderWithNullTemplatesPathStringFallback() {
+ // Tests the Optional.ofNullable(this.templatesPathString).orElse(TemplateEngine.PATH) branch
+ Configuration conf = FreemarkerModule.create().setTemplatesPath((String) null).build(env);
+
+ assertNotNull(conf.getTemplateLoader());
+ }
+
// --- DEFAULT TEMPLATE LOADER RESOLUTION TESTS ---
@Test
diff --git a/modules/jooby-handlebars/src/main/java/io/jooby/handlebars/HandlebarsModule.java b/modules/jooby-handlebars/src/main/java/io/jooby/handlebars/HandlebarsModule.java
index 57452d86d8..cdee73c705 100644
--- a/modules/jooby-handlebars/src/main/java/io/jooby/handlebars/HandlebarsModule.java
+++ b/modules/jooby-handlebars/src/main/java/io/jooby/handlebars/HandlebarsModule.java
@@ -257,8 +257,9 @@ public void install(Jooby application) throws Exception {
.setTemplatesPath(templatesPath)
.build(application.getEnvironment());
}
- application.encoder(
- new HandlebarsTemplateEngine(handlebars, resolvers.toArray(new ValueResolver[0]), EXT));
+ var templateEngine =
+ new HandlebarsTemplateEngine(handlebars, resolvers.toArray(new ValueResolver[0]), EXT);
+ application.encoder(templateEngine);
var services = application.getServices();
services.put(Handlebars.class, handlebars);
diff --git a/modules/jooby-handlebars/src/test/java/io/jooby/handlebars/HandlebarsModuleTest.java b/modules/jooby-handlebars/src/test/java/io/jooby/handlebars/HandlebarsModuleTest.java
index ddd8c4cfbe..30a427d905 100644
--- a/modules/jooby-handlebars/src/test/java/io/jooby/handlebars/HandlebarsModuleTest.java
+++ b/modules/jooby-handlebars/src/test/java/io/jooby/handlebars/HandlebarsModuleTest.java
@@ -167,6 +167,12 @@ void testDefaultTemplateLoaderClasspathFallback() {
@Test
void testClassPathTemplateLoaderResourceResolution() throws IOException {
+ URL resourceUrl = new URL("file:///dummy");
+ ClassLoader classLoader = mock(ClassLoader.class);
+
+ when(env.getClassLoader()).thenReturn(classLoader);
+ when(classLoader.getResource(anyString())).thenReturn(resourceUrl);
+
Handlebars hbs =
HandlebarsModule.create()
.setTemplatesPath("this_path_does_not_exist_on_file_system")
@@ -174,10 +180,14 @@ void testClassPathTemplateLoaderResourceResolution() throws IOException {
ClassPathTemplateLoader loader = (ClassPathTemplateLoader) hbs.getLoader();
- // Test the overridden getResource method uses the Environment's ClassLoader
- URL resourceUrl = new URL("file:///dummy");
- ClassLoader classLoader = mock(ClassLoader.class);
- when(env.getClassLoader()).thenReturn(classLoader);
- when(classLoader.getResource("test.hbs")).thenReturn(resourceUrl);
+ try {
+ loader.sourceAt("test");
+ } catch (Exception e) {
+ // It might throw an exception attempting to read "file:///dummy",
+ // but that's fine for this test since we only care about resolution.
+ }
+
+ verify(env).getClassLoader();
+ verify(classLoader).getResource(anyString());
}
}
diff --git a/modules/jooby-htmx/pom.xml b/modules/jooby-htmx/pom.xml
new file mode 100644
index 0000000000..db64520c52
--- /dev/null
+++ b/modules/jooby-htmx/pom.xml
@@ -0,0 +1,40 @@
+
+
+
+ 4.0.0
+
+
+ io.jooby
+ modules
+ 4.4.1-SNAPSHOT
+
+ jooby-htmx
+ jooby-htmx
+
+
+
+ io.jooby
+ jooby
+ ${jooby.version}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+
+ org.jacoco
+ org.jacoco.agent
+ runtime
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxError.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxError.java
new file mode 100644
index 0000000000..0d5700608e
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxError.java
@@ -0,0 +1,37 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Defines the HTMX error template to render if a validation or parameter binding exception occurs.
+ *
+ * @author edgar
+ * @since 4.5.0
+ */
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.SOURCE)
+public @interface HxError {
+ /**
+ * The fallback template to render if a validation or parameter binding exception occurs.
+ *
+ * @return The error template path.
+ */
+ String value();
+
+ /**
+ * Automatically appends an {@code HX-Retarget} header when an exception triggers the {@link
+ * #value()} ()}. Useful for redirecting failed form submissions back to the form container
+ * instead of the default target.
+ *
+ * @return The CSS selector of the error target.
+ */
+ String target() default "";
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOob.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOob.java
new file mode 100644
index 0000000000..922591ed56
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOob.java
@@ -0,0 +1,31 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Declares an additional template to be rendered and streamed as an Out-of-Band (OOB) swap.
+ *
+ * Multiple {@code @HxOob} annotations can be applied to a single method. The generated encoder
+ * will stream the primary view and all OOB views sequentially. Note that the method's return value
+ * must provide the necessary model data for all views.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+@Repeatable(HxOobs.class)
+public @interface HxOob {
+ /**
+ * The classpath location of the template file to render as an OOB swap.
+ *
+ * @return The template path.
+ */
+ String value();
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOobs.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOobs.java
new file mode 100644
index 0000000000..2fb5c15244
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOobs.java
@@ -0,0 +1,18 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Container annotation for repeatable {@link HxOob} annotations. */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+public @interface HxOobs {
+ HxOob[] value();
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxPushUrl.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxPushUrl.java
new file mode 100644
index 0000000000..d5fba47992
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxPushUrl.java
@@ -0,0 +1,30 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Instructs HTMX to push a new URL into the browser's history stack. Maps to the {@code
+ * HX-Push-Url} header.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+public @interface HxPushUrl {
+ /**
+ * The URL to push to the history stack.
+ *
+ *
Use {@code "true"} (the default) to push the current request URL. Use {@code "false"} to
+ * explicitly prevent history pushing. Provide a path (e.g., {@code "/users/list"}) to push a
+ * specific URL.
+ *
+ * @return The URL directive.
+ */
+ String value() default "true";
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRedirect.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRedirect.java
new file mode 100644
index 0000000000..4a0f0c1985
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRedirect.java
@@ -0,0 +1,26 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Instructs HTMX to perform a full-page client-side redirect to a new URL, bypassing standard swap
+ * logic. Maps to the {@code HX-Redirect} header.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+public @interface HxRedirect {
+ /**
+ * The URL to redirect the client to.
+ *
+ * @return The destination URL.
+ */
+ String value();
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRefresh.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRefresh.java
new file mode 100644
index 0000000000..dcb63cb2d7
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRefresh.java
@@ -0,0 +1,19 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Instructs HTMX to perform a full-page reload of the current client-side context. Maps to the
+ * {@code HX-Refresh: true} header.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+public @interface HxRefresh {}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxSwap.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxSwap.java
new file mode 100644
index 0000000000..8adb140435
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxSwap.java
@@ -0,0 +1,28 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Instructs HTMX to override the client-side swap style for the response. Maps to the {@code
+ * HX-Reswap} header.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+public @interface HxSwap {
+ /**
+ * The HTMX swap style, optionally including modifiers.
+ *
+ *
Examples: {@code "innerHTML"}, {@code "outerHTML"}, {@code "outerHTML scroll:top"}
+ *
+ * @return The swap style string.
+ */
+ String value();
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTarget.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTarget.java
new file mode 100644
index 0000000000..0bad0a03b7
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTarget.java
@@ -0,0 +1,26 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Instructs HTMX to swap the response into a different target element than the one that initiated
+ * the request. Maps to the {@code HX-Retarget} header.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+public @interface HxTarget {
+ /**
+ * The CSS selector of the target element.
+ *
+ * @return The CSS selector.
+ */
+ String value();
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTrigger.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTrigger.java
new file mode 100644
index 0000000000..08b6d56dc3
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTrigger.java
@@ -0,0 +1,48 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Triggers a client-side event upon successful response. Maps to the {@code HX-Trigger}, {@code
+ * HX-Trigger-After-Settle}, or {@code HX-Trigger-After-Swap} headers.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+@Repeatable(HxTriggers.class)
+public @interface HxTrigger {
+
+ /**
+ * The name of the client-side event to trigger.
+ *
+ * @return The event name.
+ */
+ String value();
+
+ /**
+ * The lifecycle phase at which the event should be triggered.
+ *
+ * @return The trigger phase. Defaults to {@link Phase#TRIGGER}.
+ */
+ Phase phase() default Phase.TRIGGER;
+
+ /** Represents the HTMX trigger lifecycle headers. */
+ enum Phase {
+ /** Appends to the {@code HX-Trigger} header. */
+ TRIGGER,
+
+ /** Appends to the {@code HX-Trigger-After-Settle} header. */
+ AFTER_SETTLE,
+
+ /** Appends to the {@code HX-Trigger-After-Swap} header. */
+ AFTER_SWAP
+ }
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTriggers.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTriggers.java
new file mode 100644
index 0000000000..d76fa1f2ae
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTriggers.java
@@ -0,0 +1,18 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Container annotation for repeatable {@link HxTrigger} annotations. */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+public @interface HxTriggers {
+ HxTrigger[] value();
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxView.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxView.java
new file mode 100644
index 0000000000..ba9bf30755
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxView.java
@@ -0,0 +1,56 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation.htmx;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Defines the HTMX view rendering strategy for an MVC route.
+ *
+ *
This annotation is intercepted by the HTMX APT generator to produce a {@code ModelAndView}.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.SOURCE)
+public @interface HxView {
+
+ /**
+ * The classpath location of the template file (e.g., "users/profile").
+ *
+ * @return The template path.
+ */
+ String value();
+
+ /**
+ * Defines the outer HTML layout (or "SPA Shell") that wraps this partial view.
+ *
+ *
This attribute enables seamless deep-linking and full-page refreshes in an HTMX application.
+ * It allows a single controller method to serve both dynamic UI fragments and fully-formed HTML
+ * pages depending on the origin of the incoming request.
+ *
+ *
How it works:
+ *
+ *
+ * - HTMX Requests: If the request contains the {@code HX-Request: true} header, this
+ * layout attribute is completely ignored. The framework responds only with the partial view
+ * defined in the primary {@code value()} attribute, ensuring fast, targeted DOM swaps.
+ *
- Standard Browser Requests: If a user accesses the endpoint directly via the URL
+ * bar, a bookmark, or an {@code F5} refresh, the framework intercepts the request. It
+ * renders this layout file.
+ *
+ *
+ * Template Integration:
+ *
+ * When a layout fallback is triggered, the framework automatically injects the name of the
+ * target partial view into the response model under the {@code childView} key. Your layout file
+ * must use your template engine's dynamic include syntax to render the child view.
+ *
+ * @return The path to the layout template file, or an empty string if no layout is required.
+ */
+ String layout() default "";
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java
new file mode 100644
index 0000000000..89a06acca2
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java
@@ -0,0 +1,249 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+
+import io.jooby.Context;
+import io.jooby.json.JsonEncoder;
+
+/**
+ * Provides a fluent API for interacting with HTMX specific HTTP headers. * This context wraps a
+ * standard Jooby {@link Context} and makes it easy to read incoming HTMX request states and safely
+ * build outgoing HTMX response headers, including complex JSON-encoded trigger payloads.
+ *
+ * @see HTMX Reference
+ * @author edgar
+ * @since 4.5.0
+ */
+public class HtmxContext {
+
+ private final Context ctx;
+
+ private final Map triggers = new LinkedHashMap<>();
+ private final Map triggersAfterSettle = new LinkedHashMap<>();
+ private final Map triggersAfterSwap = new LinkedHashMap<>();
+
+ /**
+ * Creates a new HTMX context.
+ *
+ * @param ctx The current Jooby HTTP context.
+ */
+ public HtmxContext(Context ctx) {
+ this.ctx = ctx;
+ }
+
+ // --- Request State Readers ---
+
+ /**
+ * Indicates that the request is via an element using hx-boost.
+ *
+ * @return True if the {@code HX-Boosted} header is present and true.
+ */
+ public boolean isBoosted() {
+ return ctx.header("HX-Boosted").booleanValue(false);
+ }
+
+ /**
+ * Indicates that the request is a standard HTMX request.
+ *
+ * @return True if the {@code HX-Request} header is present and true.
+ */
+ public boolean isHtmxRequest() {
+ return ctx.header("HX-Request").booleanValue(false);
+ }
+
+ /**
+ * Indicates if the request is for history restoration after a miss in the local history cache.
+ *
+ * @return True if the {@code HX-History-Restore-Request} header is present and true.
+ */
+ public boolean isHistoryRestoreRequest() {
+ return ctx.header("HX-History-Restore-Request").booleanValue(false);
+ }
+
+ /**
+ * Retrieves the current URL of the browser that made the HTMX request.
+ *
+ * @return The value of the {@code HX-Current-Url} header, or null if missing.
+ */
+ public @Nullable String getCurrentUrl() {
+ return ctx.header("HX-Current-Url").valueOrNull();
+ }
+
+ /**
+ * Retrieves the id of the target element if it exists.
+ *
+ * @return The value of the {@code HX-Target} header, or null if missing.
+ */
+ public @Nullable String getTarget() {
+ return ctx.header("HX-Target").valueOrNull();
+ }
+
+ // --- Response Header Modifiers ---
+
+ /**
+ * Pushes a new url into the history stack.
+ *
+ * @param url The URL to push into the history stack.
+ * @return This context for method chaining.
+ */
+ public HtmxContext pushUrl(String url) {
+ ctx.setResponseHeader("HX-Push-Url", url);
+ return this;
+ }
+
+ /**
+ * Replaces the current URL in the location bar.
+ *
+ * @param url The URL to replace in the location bar.
+ * @return This context for method chaining.
+ */
+ public HtmxContext replaceUrl(String url) {
+ ctx.setResponseHeader("HX-Replace-Url", url);
+ return this;
+ }
+
+ /**
+ * Forces a client-side redirect to a new location.
+ *
+ * @param url The target URL for the redirect.
+ * @return This context for method chaining.
+ */
+ public HtmxContext redirect(String url) {
+ ctx.setResponseHeader("HX-Redirect", url);
+ return this;
+ }
+
+ /**
+ * Instructs the client side to do a full refresh of the page.
+ *
+ * @return This context for method chaining.
+ */
+ public HtmxContext refresh() {
+ ctx.setResponseHeader("HX-Refresh", true);
+ return this;
+ }
+
+ /**
+ * Specifies how the response will be swapped, overriding the default behavior.
+ *
+ * @param swap The swap strategy (e.g., innerHTML, outerHTML, beforebegin).
+ * @return This context for method chaining.
+ */
+ public HtmxContext reswap(String swap) {
+ ctx.setResponseHeader("HX-Reswap", swap);
+ return this;
+ }
+
+ /**
+ * Updates the target of the content update to a different element on the page.
+ *
+ * @param target A CSS selector representing the new target element.
+ * @return This context for method chaining.
+ */
+ public HtmxContext retarget(String target) {
+ ctx.setResponseHeader("HX-Retarget", target);
+ return this;
+ }
+
+ // --- Trigger Builders (Object Payloads) ---
+
+ /**
+ * Triggers a client-side event as soon as the response is received.
+ *
+ * @param eventName The name of the event to trigger.
+ * @return This context for method chaining.
+ */
+ public HtmxContext trigger(String eventName) {
+ this.triggers.put(eventName, null);
+ updateTriggerHeader("HX-Trigger", triggers);
+ return this;
+ }
+
+ /**
+ * Triggers a client-side event with an attached data payload.
+ *
+ * @param eventName The name of the event to trigger.
+ * @param payload The data object to send with the event.
+ * @return This context for method chaining.
+ */
+ public HtmxContext trigger(String eventName, @Nullable Object payload) {
+ this.triggers.put(eventName, payload);
+ updateTriggerHeader("HX-Trigger", triggers);
+ return this;
+ }
+
+ /**
+ * Triggers a client-side event after the settling step.
+ *
+ * @param eventName The name of the event to trigger.
+ * @return This context for method chaining.
+ */
+ public HtmxContext triggerAfterSettle(String eventName) {
+ this.triggersAfterSettle.put(eventName, null);
+ updateTriggerHeader("HX-Trigger-After-Settle", triggersAfterSettle);
+ return this;
+ }
+
+ /**
+ * Triggers a client-side event with a payload after the settling step.
+ *
+ * @param eventName The name of the event to trigger.
+ * @param payload The data object to send with the event.
+ * @return This context for method chaining.
+ */
+ public HtmxContext triggerAfterSettle(String eventName, @Nullable Object payload) {
+ this.triggersAfterSettle.put(eventName, payload);
+ updateTriggerHeader("HX-Trigger-After-Settle", triggersAfterSettle);
+ return this;
+ }
+
+ /**
+ * Triggers a client-side event after the swap step.
+ *
+ * @param eventName The name of the event to trigger.
+ * @return This context for method chaining.
+ */
+ public HtmxContext triggerAfterSwap(String eventName) {
+ this.triggersAfterSwap.put(eventName, null);
+ updateTriggerHeader("HX-Trigger-After-Swap", triggersAfterSwap);
+ return this;
+ }
+
+ /**
+ * Triggers a client-side event with a payload after the swap step.
+ *
+ * @param eventName The name of the event to trigger.
+ * @param payload The data object to send with the event.
+ * @return This context for method chaining.
+ */
+ public HtmxContext triggerAfterSwap(String eventName, @Nullable Object payload) {
+ this.triggersAfterSwap.put(eventName, payload);
+ updateTriggerHeader("HX-Trigger-After-Swap", triggersAfterSwap);
+ return this;
+ }
+
+ // --- Safe JSON Encoding ---
+
+ private void updateTriggerHeader(String headerName, Map triggerMap) {
+ if (triggerMap.isEmpty()) return;
+
+ boolean hasPayloads = triggerMap.values().stream().anyMatch(Objects::nonNull);
+
+ if (!hasPayloads) {
+ // No objects to serialize, safe to use simple comma separation
+ ctx.setResponseHeader(headerName, String.join(", ", triggerMap.keySet()));
+ } else {
+ var encoder = ctx.require(JsonEncoder.class);
+ ctx.setResponseHeader(headerName, encoder.encode(triggerMap));
+ }
+ }
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxDirectAccessException.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxDirectAccessException.java
new file mode 100644
index 0000000000..c3b905b8f6
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxDirectAccessException.java
@@ -0,0 +1,31 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import io.jooby.StatusCode;
+import io.jooby.exception.StatusCodeException;
+
+/**
+ * Exception thrown to indicate that a direct access attempt to resources via HTMX has been blocked.
+ * This typically corresponds to an HTTP 406 Not Acceptable status.
+ *
+ * HtmxDirectAccessException is a specialized form of {@code StatusCodeException} that sets.
+ *
+ * @author edgar
+ * @since 4.5.0
+ */
+public class HtmxDirectAccessException extends StatusCodeException {
+ /**
+ * Constructs a new {@code HtmxDirectAccessException} with a default HTTP status code of 406 Not
+ * Acceptable. This exception is used to signal that a direct access attempt to HTMX resources has
+ * been disallowed.
+ *
+ * @param message The error message.
+ */
+ public HtmxDirectAccessException(String message) {
+ super(StatusCode.NOT_ACCEPTABLE, message);
+ }
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java
new file mode 100644
index 0000000000..d6831967b6
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java
@@ -0,0 +1,60 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import org.slf4j.event.Level;
+
+import io.jooby.Context;
+import io.jooby.ErrorHandler;
+import io.jooby.StatusCode;
+
+/**
+ * A specialized error handler designed to intercept and format exceptions specifically for HTMX
+ * requests.
+ *
+ *
By implementing this interface, developers can seamlessly convert global server crashes or
+ * validation failures (e.g., HTTP 422 or 500) into graceful HTMX responses, such as Out-Of-Band
+ * (OOB) toast notifications, preventing raw HTML stack traces from breaking the client's DOM. *
+ *
+ *
Standard browser requests will bypass this handler and fall back to Jooby's default error
+ * pages.
+ */
+public interface HtmxErrorHandler {
+
+ /**
+ * Processes the error and generates an appropriate HTMX response.
+ *
+ * @param ctx The current HTTP context.
+ * @param cause The exception that was thrown.
+ * @param code The resolved HTTP status code for the error.
+ * @return An {@link HtmxResponse} containing the partial views or triggers to send to the client.
+ */
+ HtmxResponse apply(Context ctx, Throwable cause, StatusCode code);
+
+ /**
+ * Converts this HTMX-specific error handler into an {@link ErrorHandler}.
+ *
+ *
This method automatically applies guard clauses: it ensures the request is an actual HTMX
+ * request (via the {@code HX-Request} header) and ignores {@link HtmxDirectAccessException}
+ * (which is deliberately thrown to reject direct browser access to partials).
+ *
+ * @return An ErrorHandler that wraps this implementation.
+ */
+ default ErrorHandler toErrorHandler() {
+ return (ctx, cause, code) -> {
+ // error is thrown on bad Htmx request, ignore we can't handle it.
+ if (!(cause instanceof HtmxDirectAccessException)
+ && ctx.header("HX-Request").booleanValue(false)) {
+ var log = ctx.getRouter().getLog();
+ var level = code.value() < 500 ? Level.DEBUG : Level.ERROR;
+ log.atLevel(level).log(ErrorHandler.errorMessage(ctx, code), cause);
+ ErrorHandler.errorMessage(
+ ctx, code); // Note: This line has no side effects and can be safely removed.
+ apply(ctx, cause, code).send(ctx);
+ }
+ };
+ }
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModelAndView.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModelAndView.java
new file mode 100644
index 0000000000..ae39e004f2
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModelAndView.java
@@ -0,0 +1,69 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import java.util.*;
+
+import org.jspecify.annotations.Nullable;
+
+import io.jooby.ModelAndView;
+
+/**
+ * A specialized view carrier for HTMX Out-of-Band (OOB) swaps.
+ *
+ *
The HTMX APT generator instantiates this class when a controller method uses {@code @HxOob}
+ * annotations alongside a primary {@code @HxView}. It instructs the {@link HtmxTemplateEngine} to
+ * sequentially render multiple templates using the same model.
+ */
+public class HtmxModelAndView extends ModelAndView implements Iterable> {
+
+ private final Map oobViews = new LinkedHashMap<>();
+
+ /**
+ * Creates a new HTMX multi-view.
+ *
+ * @param primaryView The main template path (e.g., from {@code @HxView}).
+ * @param model The data model shared across all templates.
+ */
+ public HtmxModelAndView(String primaryView, @Nullable T model) {
+ super(primaryView, model);
+ }
+
+ /**
+ * Adds an Out-of-Band view to the rendering pipeline.
+ *
+ * @param view The OOB template path.
+ * @return This instance.
+ */
+ public HtmxModelAndView addOob(String view) {
+ return addOob(view, model);
+ }
+
+ /**
+ * Adds an Out-of-Band (OOB) view and its associated model to the rendering pipeline.
+ *
+ * @param view The template path for the OOB view.
+ * @param model The data model associated with the specified OOB view.
+ * @return The current instance of {@code HtmxModelAndView}.
+ */
+ public HtmxModelAndView addOob(String view, Object model) {
+ this.oobViews.put(view, model);
+ return this;
+ }
+
+ @Override
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public Iterator> iterator() {
+ var views = new ArrayList();
+ views.add(ModelAndView.of(getView(), model));
+
+ for (var oob : oobViews.entrySet()) {
+ views.add(ModelAndView.of(oob.getKey(), oob.getValue()));
+ }
+
+ return views.iterator();
+ }
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java
new file mode 100644
index 0000000000..fbf6f91919
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java
@@ -0,0 +1,66 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import org.jspecify.annotations.Nullable;
+
+import io.jooby.Extension;
+import io.jooby.Jooby;
+
+/**
+ * The primary extension for enabling first-class HTMX support in a Jooby application.
+ *
+ * Installing this module registers the {@link HtmxTemplateEngine}, which intercepts {@code
+ * HtmxModelAndView} responses and enables advanced features like Out-Of-Band (OOB) template
+ * swapping and declarative HTTP headers.
+ *
+ *
Usage:
+ *
+ *
{@code
+ * {
+ * // Basic installation
+ * install(new HtmxModule());
+ *
+ * // Installation with a global HTMX error handler
+ * install(new HtmxModule(new MyHtmxErrorHandler()));
+ * }
+ * }
+ */
+public class HtmxModule implements Extension {
+
+ private @Nullable HtmxErrorHandler errorHandler;
+
+ /**
+ * Creates a new HTMX module with a custom global error handler.
+ *
+ * @param errorHandler The handler to process and format exceptions into HTMX-compatible
+ * responses.
+ */
+ public HtmxModule(HtmxErrorHandler errorHandler) {
+ this.errorHandler = errorHandler;
+ }
+
+ /** Creates a new HTMX module with default settings. */
+ public HtmxModule() {}
+
+ /**
+ * Installs the HTMX extension into the Jooby application.
+ *
+ * @param app The target Jooby application.
+ * @throws Exception If an error occurs during installation.
+ */
+ @Override
+ public void install(Jooby app) throws Exception {
+
+ if (errorHandler != null) {
+ app.error(errorHandler.toErrorHandler());
+ }
+ var htmxEngine = new HtmxTemplateEngine();
+ app.encoder(htmxEngine);
+ // validate and setup engines:
+ app.onStarting(() -> htmxEngine.init(app));
+ }
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java
new file mode 100644
index 0000000000..4b661515b0
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java
@@ -0,0 +1,310 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import static java.util.Optional.ofNullable;
+
+import java.util.*;
+
+import org.jspecify.annotations.Nullable;
+
+import io.jooby.Context;
+import io.jooby.StatusCode;
+import io.jooby.json.JsonEncoder;
+
+/**
+ * An imperative builder for constructing HTMX responses safely and fluently.
+ *
+ * This class allows developers to explicitly orchestrate complex HTMX interactions directly from
+ * the controller, such as triggering client-side events, chaining Out-Of-Band (OOB) template swaps,
+ * and managing HTTP status code behaviors (e.g., automatically upgrading a 204 No Content to a 200
+ * OK if HTML views are attached).
+ */
+public class HtmxResponse {
+
+ private final @Nullable String view;
+ private final Object model;
+ private @Nullable StatusCode status;
+
+ private final Map headers = new LinkedHashMap<>();
+
+ private final Map oobs = new LinkedHashMap<>();
+ private final Map triggers = new LinkedHashMap<>();
+ private final Map triggersAfterSettle = new LinkedHashMap<>();
+ private final Map triggersAfterSwap = new LinkedHashMap<>();
+
+ private HtmxResponse(@Nullable String view, Object model) {
+ this.view = view;
+ this.model = model;
+ }
+
+ /**
+ * Creates an HtmxResponse that renders a specific view template with the provided model.
+ *
+ * @param view The classpath location of the template.
+ * @param model The data model to pass to the template engine.
+ * @return A new HtmxResponse instance.
+ */
+ public static HtmxResponse view(String view, @Nullable Object model) {
+ return new HtmxResponse(view, ofNullable(model).orElse(Map.of()));
+ }
+
+ /**
+ * Creates an HtmxResponse that renders a specific view template with an empty model.
+ *
+ * @param view The classpath location of the template.
+ * @return A new HtmxResponse instance.
+ */
+ public static HtmxResponse view(String view) {
+ return view(view, null);
+ }
+
+ /**
+ * Creates an empty action-only response.
+ *
+ * Defaults the HTTP status to {@link StatusCode#NO_CONTENT} (204). HTMX interprets a 204 as a
+ * successful request but will not attempt to swap any content into the DOM.
+ *
+ * @return A new HtmxResponse instance.
+ */
+ public static HtmxResponse empty() {
+ return empty(StatusCode.NO_CONTENT);
+ }
+
+ /**
+ * Creates an empty action-only response with a specific status code.
+ *
+ * @param status The status code to return.
+ * @return A new HtmxResponse instance.
+ */
+ public static HtmxResponse empty(StatusCode status) {
+ var res = new HtmxResponse(null, Map.of());
+ res.status = status;
+ return res;
+ }
+
+ // --- Builder Methods ---
+
+ /**
+ * Sets the HTTP status code for the response.
+ *
+ * @param status The status code.
+ * @return This builder instance.
+ */
+ public HtmxResponse status(StatusCode status) {
+ this.status = status;
+ return this;
+ }
+
+ /**
+ * Triggers a client-side event immediately using the {@code HX-Trigger} header.
+ *
+ * @param eventName The name of the event to trigger.
+ * @return This builder instance.
+ */
+ public HtmxResponse trigger(String eventName) {
+ this.triggers.put(eventName, null);
+ return this;
+ }
+
+ /**
+ * Triggers a client-side event with a JSON payload immediately using the {@code HX-Trigger}
+ * header.
+ *
+ * @param eventName The name of the event to trigger.
+ * @param jsonPayload The event detail to be serialized into JSON.
+ * @return This builder instance.
+ */
+ public HtmxResponse trigger(String eventName, Object jsonPayload) {
+ this.triggers.put(eventName, jsonPayload);
+ return this;
+ }
+
+ /**
+ * Triggers a client-side event after the settling phase using {@code HX-Trigger-After-Settle}.
+ *
+ * @param eventName The name of the event to trigger.
+ * @param value The event detail to be serialized into JSON, or null.
+ * @return This builder instance.
+ */
+ public HtmxResponse triggerAfterSettle(String eventName, @Nullable Object value) {
+ this.triggersAfterSettle.put(eventName, value);
+ return this;
+ }
+
+ /**
+ * Triggers a client-side event after the swap phase using {@code HX-Trigger-After-Swap}.
+ *
+ * @param eventName The name of the event to trigger.
+ * @param value The event detail to be serialized into JSON, or null.
+ * @return This builder instance.
+ */
+ public HtmxResponse triggerAfterSwap(String eventName, @Nullable Object value) {
+ this.triggersAfterSwap.put(eventName, value);
+ return this;
+ }
+
+ /**
+ * Instructs HTMX to swap the response into a different target element. Sets the {@code
+ * HX-Retarget} header.
+ *
+ * @param targetSelector The CSS selector of the new target.
+ * @return This builder instance.
+ */
+ public HtmxResponse target(String targetSelector) {
+ return header("HX-Retarget", targetSelector);
+ }
+
+ /**
+ * Overrides the client-side swap logic for this specific response. Sets the {@code HX-Reswap}
+ * header.
+ *
+ * @param swapStyle The swap style (e.g., "innerHTML", "outerHTML", "none").
+ * @return This builder instance.
+ */
+ public HtmxResponse swap(String swapStyle) {
+ return header("HX-Reswap", swapStyle);
+ }
+
+ /**
+ * Pushes a new URL into the browser's history stack. Sets the {@code HX-Push-Url} header.
+ *
+ * @param url The URL to push. Use "false" to explicitly prevent history pushing.
+ * @return This builder instance.
+ */
+ public HtmxResponse pushUrl(String url) {
+ return header("HX-Push-Url", url);
+ }
+
+ /**
+ * Forces the client to perform a full-page redirect to the specified URL. Sets the {@code
+ * HX-Redirect} header.
+ *
+ * @param url The destination URL.
+ * @return This builder instance.
+ */
+ public HtmxResponse redirect(String url) {
+ return header("HX-Redirect", url);
+ }
+
+ /**
+ * Forces the client to perform a full-page reload. Sets the {@code HX-Refresh: true} header.
+ *
+ * @return This builder instance.
+ */
+ public HtmxResponse refresh() {
+ return header("HX-Refresh", "true");
+ }
+
+ /**
+ * Adds a custom header to the HTMX response.
+ *
+ * @param name The header name.
+ * @param value The header value.
+ * @return This builder instance.
+ */
+ public HtmxResponse header(String name, String value) {
+ this.headers.put(name, value);
+ return this;
+ }
+
+ /**
+ * Instructs HTMX to render an out-of-band (OOB) swap using the specified view template. The model
+ * provided to the main response will be automatically shared with this OOB template.
+ *
+ * @param oobView The classpath location of the OOB template.
+ * @return This builder instance.
+ */
+ public HtmxResponse addOob(String oobView) {
+ return addOob(oobView, model);
+ }
+
+ /**
+ * Adds an out-of-band (OOB) swap to this response, using the specified view template and
+ * associated data model. The OOB swap allows rendering an HTML fragment outside the regular
+ * content replacement target.
+ *
+ * @param oobView The classpath location of the OOB view template.
+ * @param model The data model to associate with the OOB view template.
+ * @return This builder instance.
+ */
+ public HtmxResponse addOob(String oobView, @Nullable Object model) {
+ this.oobs.put(oobView, ofNullable(model).orElse(Map.of()));
+ return this;
+ }
+
+ /**
+ * Sends the HTTP response based on the configuration of the current HtmxResponse instance. If a
+ * view is set, it returns a rendered {@code HtmxModelAndView} containing the view and model.
+ * Otherwise, it sends the HTTP status directly through the provided context. Headers are written
+ * to the context before forming the response.
+ *
+ * @param ctx The HTTP {@code Context} object representing the current request and response
+ * context.
+ * @return The HTTP context object.
+ */
+ public Context send(Context ctx) {
+ writeHeaders(ctx);
+ var hasViews = view != null || !oobs.isEmpty();
+ if (status != null) {
+ if (status == StatusCode.NO_CONTENT && hasViews) {
+ // HTTP 204 cannot contain a body. Upgrade to 200 OK if we are sending HTML.
+ ctx.setResponseCode(StatusCode.OK);
+ } else {
+ // Respect user's 422, 201, etc.
+ ctx.setResponseCode(status);
+ }
+ }
+ if (hasViews) {
+ HtmxModelAndView> htmxView;
+ if (view == null) {
+ var oobIter = oobs.entrySet().iterator();
+ var firstOob = oobIter.next();
+ htmxView = new HtmxModelAndView<>(firstOob.getKey(), firstOob.getValue());
+
+ while (oobIter.hasNext()) {
+ var nextOob = oobIter.next();
+ htmxView.addOob(nextOob.getKey(), nextOob.getValue());
+ }
+ } else {
+ htmxView = new HtmxModelAndView<>(view, model);
+ oobs.forEach(htmxView::addOob);
+ }
+ return ctx.render(htmxView);
+ } else {
+ return ctx.send(status != null ? status : StatusCode.NO_CONTENT);
+ }
+ }
+
+ /**
+ * Called internally to safely encode and write all headers directly to the Jooby Context.
+ *
+ * @param ctx The active request context.
+ */
+ private void writeHeaders(Context ctx) {
+ // Write simple static headers
+ headers.forEach(ctx::setResponseHeader);
+
+ // Safely encode and write dynamic triggers
+ writeTriggerMap(ctx, "HX-Trigger", triggers);
+ writeTriggerMap(ctx, "HX-Trigger-After-Settle", triggersAfterSettle);
+ writeTriggerMap(ctx, "HX-Trigger-After-Swap", triggersAfterSwap);
+ }
+
+ private void writeTriggerMap(
+ Context ctx, String headerName, Map triggerMap) {
+ if (triggerMap.isEmpty()) return;
+
+ boolean hasPayloads = triggerMap.values().stream().anyMatch(Objects::nonNull);
+
+ if (!hasPayloads) {
+ ctx.setResponseHeader(headerName, String.join(", ", triggerMap.keySet()));
+ } else {
+ var encoder = ctx.require(JsonEncoder.class);
+ ctx.setResponseHeader(headerName, encoder.encode(triggerMap));
+ }
+ }
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java
new file mode 100644
index 0000000000..46f9b72b54
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java
@@ -0,0 +1,82 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jspecify.annotations.Nullable;
+
+import io.jooby.*;
+import io.jooby.output.Output;
+
+/**
+ * Intercepts {@link HtmxModelAndView} returns and streams multiple templates sequentially to the
+ * HTMX client.
+ *
+ * Note: This class is not a standalone template engine (such as Handlebars or
+ * Freemarker). Instead, it acts as a composite delegator. When an {@link HtmxModelAndView} is
+ * detected, this engine resolves the actual, registered {@link TemplateEngine} capable of handling
+ * the views. It then uses that underlying engine to render both the primary view and all attached
+ * Out-Of-Band (OOB) views, concatenating their output into a single HTTP response payload.
+ *
+ * @author edgar
+ * @since 4.5.0
+ */
+public class HtmxTemplateEngine implements TemplateEngine.OnTop {
+
+ private List engines;
+
+ void init(Jooby app) {
+ engines = new ArrayList<>(app.getRouter().getTemplateEngines());
+ engines.remove(this);
+ if (engines.isEmpty()) {
+ throw new IllegalStateException("No template engines registered");
+ }
+ }
+
+ @Override
+ public Output render(Context ctx, ModelAndView> modelAndView) throws Exception {
+ if (modelAndView instanceof HtmxModelAndView> htmxView) {
+ var engineEncoder = resolveTemplateEngine(htmxView);
+ if (engineEncoder == null) {
+ throw new IllegalStateException(
+ "No template engine registered to handle: " + htmxView.getView());
+ }
+ var composite = ctx.getOutputFactory().newComposite();
+ for (ModelAndView> mv : htmxView) {
+ composite.write(engineEncoder.encode(ctx, mv).asByteBuffer());
+ }
+ return composite;
+ }
+ return null;
+ }
+
+ /**
+ * Resolves a {@link TemplateEngine} instance capable of rendering the specified {@link
+ * ModelAndView}. Iterates through the available template engines in the context, returning the
+ * first one that supports the provided model and view.
+ *
+ * @param mv The {@link ModelAndView} to be rendered. The method determines its compatibility with
+ * the available template engines.
+ * @return The {@link TemplateEngine} capable of rendering the provided {@link ModelAndView}, or
+ * {@code null} if no suitable engine is found.
+ */
+ private @Nullable TemplateEngine resolveTemplateEngine(ModelAndView mv) {
+ // Find the encoder that handles standard ModelAndView
+ for (var engine : engines) {
+ if (engine.supports(mv)) {
+ return engine;
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public boolean supports(ModelAndView modelAndView) {
+ return modelAndView instanceof HtmxModelAndView;
+ }
+}
diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/package-info.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/package-info.java
new file mode 100644
index 0000000000..0089f64d67
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/package-info.java
@@ -0,0 +1,47 @@
+/**
+ * Provides declarative HTMX support for Jooby MVC routes.
+ *
+ * This package contains annotations processed at compile-time by the Jooby HTMX APT generator.
+ * It allows developers to define partial HTML responses, out-of-band swaps, and dynamic client-side
+ * behaviors directly on their route methods without polluting business logic with header
+ * management.
+ *
+ *
Core Concepts
+ *
+ *
+ * - Fragments: Use {@link io.jooby.annotation.htmx.HxView} to define the HTML fragment
+ * to render.
+ *
- Content Negotiation: Define the {@code layout} attribute in {@code @HxView} to
+ * automatically handle direct browser navigation versus HTMX AJAX requests.
+ *
- Behaviors: Use annotations like {@link io.jooby.annotation.htmx.HxTrigger} or {@link
+ * io.jooby.annotation.htmx.HxTarget} to append {@code HX-} headers to the response.
+ *
+ *
+ * Example Usage
+ *
+ * {@code
+ * @Path("/users")
+ * public class UserController {
+ *
+ * @POST
+ * @HxView(
+ * value = "users/row.hbs",
+ * layout = "layouts/main.hbs",
+ * errorView = "users/form.hbs",
+ * errorTarget = "#user-form"
+ * )
+ * @HxTrigger(value = "userListUpdated", phase = Phase.AFTER_SETTLE)
+ * @HxOob("widgets/total-count")
+ * public User saveUser(UserDto dto) {
+ * // Business logic here. The APT generator handles view resolution,
+ * // validation errors, and HTMX headers.
+ * return repository.save(dto);
+ * }
+ * }
+ * }
+ *
+ * @since 4.5.0
+ * @author edgar
+ */
+@org.jspecify.annotations.NullMarked
+package io.jooby.htmx;
diff --git a/modules/jooby-htmx/src/main/java/module-info.java b/modules/jooby-htmx/src/main/java/module-info.java
new file mode 100644
index 0000000000..6e8c65ebbf
--- /dev/null
+++ b/modules/jooby-htmx/src/main/java/module-info.java
@@ -0,0 +1,54 @@
+/**
+ * Provides declarative HTMX support for Jooby MVC routes.
+ *
+ * This package contains annotations processed at compile-time by the Jooby HTMX APT generator.
+ * It allows developers to define partial HTML responses, out-of-band swaps, and dynamic client-side
+ * behaviors directly on their route methods without polluting business logic with header
+ * management.
+ *
+ *
Core Concepts
+ *
+ *
+ * - Fragments: Use {@link io.jooby.annotation.htmx.HxView} to define the HTML fragment
+ * to render.
+ *
- Content Negotiation: Define the {@code layout} attribute in {@code @HxView} to
+ * automatically handle direct browser navigation versus HTMX AJAX requests.
+ *
- Behaviors: Use annotations like {@link io.jooby.annotation.htmx.HxTrigger} or {@link
+ * io.jooby.annotation.htmx.HxTarget} to append {@code HX-} headers to the response.
+ *
+ *
+ * Example Usage
+ *
+ * {@code
+ * @Path("/users")
+ * public class UserController {
+ *
+ * @POST
+ * @HxView(
+ * value = "users/row.hbs",
+ * layout = "layouts/main.hbs",
+ * errorView = "users/form.hbs",
+ * errorTarget = "#user-form"
+ * )
+ * @HxTrigger(value = "userListUpdated", phase = Phase.AFTER_SETTLE)
+ * @HxOob("widgets/total-count")
+ * public User saveUser(UserDto dto) {
+ * // Business logic here. The APT generator handles view resolution,
+ * // validation errors, and HTMX headers.
+ * return repository.save(dto);
+ * }
+ * }
+ * }
+ *
+ * @since 4.5.0
+ * @author edgar
+ */
+module io.jooby.htmx {
+ exports io.jooby.annotation.htmx;
+ exports io.jooby.htmx;
+
+ requires io.jooby;
+ requires static org.jspecify;
+ requires typesafe.config;
+ requires org.slf4j;
+}
diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxContextTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxContextTest.java
new file mode 100644
index 0000000000..40ca1ad88e
--- /dev/null
+++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxContextTest.java
@@ -0,0 +1,177 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.jooby.Context;
+import io.jooby.json.JsonEncoder;
+import io.jooby.value.Value;
+
+class HtmxContextTest {
+
+ private Context ctx;
+ private HtmxContext htmx;
+
+ @BeforeEach
+ void setUp() {
+ ctx = mock(Context.class);
+ htmx = new HtmxContext(ctx);
+ }
+
+ // --- Reader Tests ---
+
+ @Test
+ void shouldReadBooleanHeadersWhenTrue() {
+ mockHeader("HX-Boosted", true);
+ mockHeader("HX-Request", true);
+ mockHeader("HX-History-Restore-Request", true);
+
+ assertTrue(htmx.isBoosted());
+ assertTrue(htmx.isHtmxRequest());
+ assertTrue(htmx.isHistoryRestoreRequest());
+ }
+
+ @Test
+ void shouldReadBooleanHeadersWhenFalseOrMissing() {
+ mockHeader("HX-Boosted", false);
+ mockHeader("HX-Request", false);
+ mockHeader("HX-History-Restore-Request", false);
+
+ assertFalse(htmx.isBoosted());
+ assertFalse(htmx.isHtmxRequest());
+ assertFalse(htmx.isHistoryRestoreRequest());
+ }
+
+ @Test
+ void shouldReadStringHeaders() {
+ Value urlValue = mock(Value.class);
+ when(urlValue.valueOrNull()).thenReturn("https://jooby.io");
+ when(ctx.header("HX-Current-Url")).thenReturn(urlValue);
+
+ Value targetValue = mock(Value.class);
+ when(targetValue.valueOrNull()).thenReturn("#main-div");
+ when(ctx.header("HX-Target")).thenReturn(targetValue);
+
+ assertEquals("https://jooby.io", htmx.getCurrentUrl());
+ assertEquals("#main-div", htmx.getTarget());
+ }
+
+ @Test
+ void shouldReturnNullForMissingStringHeaders() {
+ Value missingValue = mock(Value.class);
+ when(missingValue.valueOrNull()).thenReturn(null);
+ when(ctx.header("HX-Current-Url")).thenReturn(missingValue);
+ when(ctx.header("HX-Target")).thenReturn(missingValue);
+
+ assertNull(htmx.getCurrentUrl());
+ assertNull(htmx.getTarget());
+ }
+
+ // --- Modifier Tests ---
+
+ @Test
+ void shouldSetModifierHeaders() {
+ assertSame(htmx, htmx.pushUrl("/new-url"));
+ verify(ctx).setResponseHeader("HX-Push-Url", "/new-url");
+
+ assertSame(htmx, htmx.replaceUrl("/replace-url"));
+ verify(ctx).setResponseHeader("HX-Replace-Url", "/replace-url");
+
+ assertSame(htmx, htmx.redirect("/redirect"));
+ verify(ctx).setResponseHeader("HX-Redirect", "/redirect");
+
+ assertSame(htmx, htmx.refresh());
+ verify(ctx).setResponseHeader("HX-Refresh", true);
+
+ assertSame(htmx, htmx.reswap("outerHTML"));
+ verify(ctx).setResponseHeader("HX-Reswap", "outerHTML");
+
+ assertSame(htmx, htmx.retarget("#error-box"));
+ verify(ctx).setResponseHeader("HX-Retarget", "#error-box");
+ }
+
+ // --- Trigger Tests (String Join Branch) ---
+
+ @Test
+ void shouldTriggerEventsWithoutPayloads() {
+ htmx.trigger("event1");
+ verify(ctx).setResponseHeader("HX-Trigger", "event1");
+
+ // Add a second event to verify joining logic
+ htmx.trigger("event2", null);
+ verify(ctx).setResponseHeader("HX-Trigger", "event1, event2");
+
+ htmx.triggerAfterSettle("settle1");
+ verify(ctx).setResponseHeader("HX-Trigger-After-Settle", "settle1");
+
+ htmx.triggerAfterSwap("swap1");
+ verify(ctx).setResponseHeader("HX-Trigger-After-Swap", "swap1");
+ }
+
+ // --- Trigger Tests (JSON Encoder Branch) ---
+
+ @Test
+ void shouldTriggerEventsWithPayloads() {
+ JsonEncoder encoder = mock(JsonEncoder.class);
+ when(ctx.require(JsonEncoder.class)).thenReturn(encoder);
+
+ // Simulate JSON encoding output
+ when(encoder.encode(any())).thenReturn("{\"event1\":{\"key\":\"value\"}}");
+
+ Map payload = Map.of("key", "value");
+
+ // HX-Trigger
+ htmx.trigger("event1", payload);
+ verify(encoder, times(1)).encode(any());
+ verify(ctx).setResponseHeader("HX-Trigger", "{\"event1\":{\"key\":\"value\"}}");
+
+ // HX-Trigger-After-Settle
+ htmx.triggerAfterSettle("event1", payload);
+ verify(encoder, times(2)).encode(any());
+ verify(ctx).setResponseHeader("HX-Trigger-After-Settle", "{\"event1\":{\"key\":\"value\"}}");
+
+ // HX-Trigger-After-Swap
+ htmx.triggerAfterSwap("event1", payload);
+ verify(encoder, times(3)).encode(any());
+ verify(ctx).setResponseHeader("HX-Trigger-After-Swap", "{\"event1\":{\"key\":\"value\"}}");
+ }
+
+ // --- Defensive Branch Coverage ---
+
+ @Test
+ void shouldSafelyIgnoreEmptyMapInUpdateTriggerHeader() throws Exception {
+ // Uses reflection to hit the defensive `if (triggerMap.isEmpty()) return;`
+ // which is practically unreachable via public methods since .put() happens first.
+ Method updateMethod =
+ HtmxContext.class.getDeclaredMethod("updateTriggerHeader", String.class, Map.class);
+ updateMethod.setAccessible(true);
+
+ // Invoke with empty map
+ updateMethod.invoke(htmx, "HX-Trigger", Collections.emptyMap());
+
+ // Verify context was never touched
+ verify(ctx, never()).setResponseHeader(anyString(), anyString());
+ verify(ctx, never()).require(JsonEncoder.class);
+ }
+
+ // --- Helper Methods ---
+
+ private void mockHeader(String headerName, boolean value) {
+ Value val = mock(Value.class);
+ when(val.booleanValue(false)).thenReturn(value);
+ when(ctx.header(headerName)).thenReturn(val);
+ }
+}
diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxErrorHandlerTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxErrorHandlerTest.java
new file mode 100644
index 0000000000..3b574ecbc6
--- /dev/null
+++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxErrorHandlerTest.java
@@ -0,0 +1,128 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.event.Level;
+import org.slf4j.spi.LoggingEventBuilder;
+
+import io.jooby.Context;
+import io.jooby.ErrorHandler;
+import io.jooby.Router;
+import io.jooby.StatusCode;
+import io.jooby.value.Value;
+
+class HtmxErrorHandlerTest {
+
+ private Context ctx;
+ private Router router;
+ private Logger logger;
+ private LoggingEventBuilder logBuilder;
+ private Value hxRequestValue;
+
+ private boolean applyWasCalled;
+ private HtmxResponse mockResponse;
+ private HtmxErrorHandler htmxErrorHandler;
+
+ @BeforeEach
+ void setUp() {
+ ctx = mock(Context.class);
+ router = mock(Router.class);
+ logger = mock(Logger.class);
+ logBuilder = mock(LoggingEventBuilder.class);
+ hxRequestValue = mock(Value.class);
+ mockResponse = mock(HtmxResponse.class);
+
+ when(ctx.getRouter()).thenReturn(router);
+ when(router.getLog()).thenReturn(logger);
+ when(logger.atLevel(any())).thenReturn(logBuilder);
+ when(ctx.header("HX-Request")).thenReturn(hxRequestValue);
+
+ applyWasCalled = false;
+
+ // Create a concrete instance to test the default method behavior
+ htmxErrorHandler =
+ (context, cause, code) -> {
+ applyWasCalled = true;
+ return mockResponse;
+ };
+ }
+
+ @Test
+ void shouldIgnoreNonHtmxRequests() throws Exception {
+ when(hxRequestValue.booleanValue(false)).thenReturn(false);
+
+ ErrorHandler joobyHandler = htmxErrorHandler.toErrorHandler();
+ joobyHandler.apply(ctx, new RuntimeException("Test"), StatusCode.SERVER_ERROR);
+
+ // Ensure apply() was never reached
+ assertTrue(!applyWasCalled, "Apply should not be called for non-HTMX requests");
+ verify(mockResponse, never()).send(any());
+ }
+
+ @Test
+ void shouldIgnoreHtmxDirectAccessExceptions() throws Exception {
+ when(hxRequestValue.booleanValue(false)).thenReturn(true);
+
+ ErrorHandler joobyHandler = htmxErrorHandler.toErrorHandler();
+ HtmxDirectAccessException directAccessException =
+ new HtmxDirectAccessException("Direct access block");
+
+ joobyHandler.apply(ctx, directAccessException, StatusCode.NOT_ACCEPTABLE);
+
+ // Ensure apply() was never reached
+ assertTrue(!applyWasCalled, "Apply should not be called for HtmxDirectAccessException");
+ verify(mockResponse, never()).send(any());
+ }
+
+ @Test
+ void shouldHandleClientErrorsWithDebugLog() throws Exception {
+ when(hxRequestValue.booleanValue(false)).thenReturn(true);
+
+ ErrorHandler joobyHandler = htmxErrorHandler.toErrorHandler();
+ RuntimeException cause = new RuntimeException("Validation Failed");
+
+ // Act: 422 Unprocessable Entity (< 500)
+ joobyHandler.apply(ctx, cause, StatusCode.UNPROCESSABLE_ENTITY);
+
+ // Assert: Handled successfully
+ assertTrue(applyWasCalled, "Apply should be called for valid HTMX client errors");
+ verify(mockResponse).send(ctx);
+
+ // Assert: Logged at DEBUG level
+ verify(logger).atLevel(Level.DEBUG);
+ verify(logBuilder).log(anyString(), any(Throwable.class));
+ }
+
+ @Test
+ void shouldHandleServerErrorsWithErrorLog() throws Exception {
+ when(hxRequestValue.booleanValue(false)).thenReturn(true);
+
+ ErrorHandler joobyHandler = htmxErrorHandler.toErrorHandler();
+ RuntimeException cause = new RuntimeException("Database Offline");
+
+ // Act: 500 Server Error (>= 500)
+ joobyHandler.apply(ctx, cause, StatusCode.SERVER_ERROR);
+
+ // Assert: Handled successfully
+ assertTrue(applyWasCalled, "Apply should be called for valid HTMX server errors");
+ verify(mockResponse).send(ctx);
+
+ // Assert: Logged at ERROR level
+ verify(logger).atLevel(Level.ERROR);
+ verify(logBuilder).log(anyString(), any(Throwable.class));
+ }
+}
diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java
new file mode 100644
index 0000000000..37f61ddcc5
--- /dev/null
+++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java
@@ -0,0 +1,64 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.jooby.ErrorHandler;
+import io.jooby.Jooby;
+
+class HtmxModuleTest {
+
+ private Jooby app;
+
+ @BeforeEach
+ void setUp() {
+ app = mock(Jooby.class);
+ }
+
+ @Test
+ void shouldInstallWithoutErrorHandler() throws Exception {
+ HtmxModule module = new HtmxModule();
+ module.install(app);
+
+ // Verify error handler was NOT registered
+ verify(app, never()).error(any(ErrorHandler.class));
+
+ // Verify the template engine WAS registered
+ verify(app).encoder(any(HtmxTemplateEngine.class));
+
+ // Verify the init lifecycle hook was registered
+ verify(app).onStarting(any());
+ }
+
+ @Test
+ void shouldInstallWithErrorHandler() throws Exception {
+ // 1. Mock the HTMX Error Handler and its conversion method
+ HtmxErrorHandler htmxErrorHandler = mock(HtmxErrorHandler.class);
+ ErrorHandler joobyErrorHandler = mock(ErrorHandler.class);
+ when(htmxErrorHandler.toErrorHandler()).thenReturn(joobyErrorHandler);
+
+ // 2. Initialize and install the module
+ HtmxModule module = new HtmxModule(htmxErrorHandler);
+ module.install(app);
+
+ // 3. Verify the converted error handler WAS registered
+ verify(app).error(joobyErrorHandler);
+
+ // 4. Verify the template engine WAS registered
+ verify(app).encoder(any(HtmxTemplateEngine.class));
+
+ // 5. Verify the init lifecycle hook was registered
+ verify(app).onStarting(any());
+ }
+}
diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxResponseTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxResponseTest.java
new file mode 100644
index 0000000000..b948cfeafc
--- /dev/null
+++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxResponseTest.java
@@ -0,0 +1,181 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import io.jooby.Context;
+import io.jooby.StatusCode;
+import io.jooby.json.JsonEncoder;
+
+class HtmxResponseTest {
+
+ private Context ctx;
+
+ @BeforeEach
+ void setUp() {
+ ctx = mock(Context.class);
+ when(ctx.render(any())).thenReturn(ctx);
+ when(ctx.send(any(StatusCode.class))).thenReturn(ctx);
+ }
+
+ // --- Static Initializers ---
+
+ @Test
+ void shouldCreateViewResponse() {
+ var response = HtmxResponse.view("main.hbs");
+ response.send(ctx);
+
+ // Status is null by default for pure view responses, falls back to Jooby's default 200
+ verify(ctx, never()).setResponseCode(any());
+ verify(ctx).render(any(HtmxModelAndView.class));
+ }
+
+ @Test
+ void shouldCreateViewResponseWithModel() {
+ var response = HtmxResponse.view("main.hbs", Map.of("key", "val"));
+ response.send(ctx);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HtmxModelAndView.class);
+ verify(ctx).render(captor.capture());
+
+ assertEquals("main.hbs", captor.getValue().getView());
+ assertEquals(Map.of("key", "val"), captor.getValue().getModel());
+ }
+
+ @Test
+ void shouldCreateEmptyResponseAndSend204() {
+ HtmxResponse.empty().send(ctx);
+ verify(ctx).setResponseCode(StatusCode.NO_CONTENT); // status != null block
+ verify(ctx).send(StatusCode.NO_CONTENT); // fallback send block
+ }
+
+ @Test
+ void shouldHandleExplicitNullStatusForEmptyResponse() {
+ HtmxResponse.empty(null).send(ctx);
+ // If explicitly forced to null, it sends 204 fallback at the very end
+ verify(ctx).send(StatusCode.NO_CONTENT);
+ }
+
+ // --- Builder Headers & Triggers ---
+
+ @Test
+ void shouldBuildAndWriteStaticHeaders() {
+ HtmxResponse.empty()
+ .target("#app")
+ .swap("outerHTML")
+ .pushUrl("/new-url")
+ .redirect("/redirect")
+ .refresh()
+ .header("Custom-Header", "Value")
+ .send(ctx);
+
+ verify(ctx).setResponseHeader("HX-Retarget", "#app");
+ verify(ctx).setResponseHeader("HX-Reswap", "outerHTML");
+ verify(ctx).setResponseHeader("HX-Push-Url", "/new-url");
+ verify(ctx).setResponseHeader("HX-Redirect", "/redirect");
+ verify(ctx).setResponseHeader("HX-Refresh", "true");
+ verify(ctx).setResponseHeader("Custom-Header", "Value");
+ }
+
+ @Test
+ void shouldWriteTriggersWithoutPayloads() {
+ HtmxResponse.empty()
+ .trigger("event1")
+ .triggerAfterSettle("event2", null)
+ .triggerAfterSwap("event3", null)
+ .send(ctx);
+
+ verify(ctx).setResponseHeader("HX-Trigger", "event1");
+ verify(ctx).setResponseHeader("HX-Trigger-After-Settle", "event2");
+ verify(ctx).setResponseHeader("HX-Trigger-After-Swap", "event3");
+ }
+
+ @Test
+ void shouldWriteTriggersWithJsonPayloads() {
+ JsonEncoder encoder = mock(JsonEncoder.class);
+ when(ctx.require(JsonEncoder.class)).thenReturn(encoder);
+ when(encoder.encode(any())).thenReturn("{\"event\":{\"data\":1}}");
+
+ Map payload = Map.of("data", 1);
+
+ HtmxResponse.empty()
+ .trigger("event1", payload)
+ .triggerAfterSettle("event2", payload)
+ .triggerAfterSwap("event3", payload)
+ .send(ctx);
+
+ verify(encoder, times(3)).encode(any());
+ verify(ctx).setResponseHeader("HX-Trigger", "{\"event\":{\"data\":1}}");
+ verify(ctx).setResponseHeader("HX-Trigger-After-Settle", "{\"event\":{\"data\":1}}");
+ verify(ctx).setResponseHeader("HX-Trigger-After-Swap", "{\"event\":{\"data\":1}}");
+ }
+
+ // --- OOB and Status Code Intelligence ---
+
+ @Test
+ void shouldUpgrade204To200WhenSendingHtmlViews() {
+ // We create an empty response (default 204), but then add a view.
+ // It MUST upgrade to 200, because HTTP 204 strictly forbids body content!
+ HtmxResponse.empty().addOob("toast.hbs").send(ctx);
+
+ verify(ctx).setResponseCode(StatusCode.OK);
+ verify(ctx).render(any(HtmxModelAndView.class));
+ }
+
+ @Test
+ void shouldRespectExplicitCustomStatusCodeWhenSendingViews() {
+ HtmxResponse.view("form.hbs")
+ .status(StatusCode.UNPROCESSABLE_ENTITY) // 422 Validations failed
+ .send(ctx);
+
+ verify(ctx).setResponseCode(StatusCode.UNPROCESSABLE_ENTITY);
+ verify(ctx).render(any(HtmxModelAndView.class));
+ }
+
+ @Test
+ void shouldPromoteFirstOobToMainViewIfPrimaryViewIsNull() {
+ // If no primary view exists, the first OOB added becomes the root view
+ // so that HtmxModelAndView functions correctly.
+ HtmxResponse.empty()
+ .addOob("oob1.hbs", Map.of("id", 1))
+ .addOob("oob2.hbs", null) // null model falls back to empty map
+ .send(ctx);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HtmxModelAndView.class);
+ verify(ctx).render(captor.capture());
+
+ HtmxModelAndView rendered = captor.getValue();
+
+ // First OOB was promoted
+ assertEquals("oob1.hbs", rendered.getView());
+ assertEquals(Map.of("id", 1), rendered.getModel());
+ }
+
+ @Test
+ void shouldAppendOobsToPrimaryView() {
+ Object parentModel = Map.of("parent", "data");
+
+ HtmxResponse.view("main.hbs", parentModel)
+ .addOob("oob1.hbs") // Should inherit parentModel
+ .addOob("oob2.hbs", Map.of("child", "data")) // Should use custom model
+ .send(ctx);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HtmxModelAndView.class);
+ verify(ctx).render(captor.capture());
+
+ HtmxModelAndView rendered = captor.getValue();
+ assertEquals("main.hbs", rendered.getView());
+ }
+}
diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java
new file mode 100644
index 0000000000..284369feb4
--- /dev/null
+++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java
@@ -0,0 +1,157 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.jooby.Context;
+import io.jooby.Jooby;
+import io.jooby.ModelAndView;
+import io.jooby.Router;
+import io.jooby.TemplateEngine;
+import io.jooby.output.BufferedOutput;
+import io.jooby.output.Output;
+import io.jooby.output.OutputFactory;
+
+class HtmxTemplateEngineTest {
+
+ private HtmxTemplateEngine engine;
+ private Context ctx;
+ private Router router;
+ private Jooby app;
+
+ @BeforeEach
+ void setUp() {
+ engine = new HtmxTemplateEngine();
+ ctx = mock(Context.class);
+ router = mock(Router.class);
+ app = mock(Jooby.class);
+
+ when(ctx.getRouter()).thenReturn(router);
+ when(app.getRouter()).thenReturn(router);
+ }
+
+ // --- Lifecycle / Init Tests ---
+
+ @Test
+ void shouldThrowIllegalStateExceptionWhenNoOtherTemplateEnginesRegistered() {
+ // Router only has the HtmxTemplateEngine registered, no underlying engines like Handlebars
+ when(router.getTemplateEngines()).thenReturn(List.of(engine));
+
+ IllegalStateException ex = assertThrows(IllegalStateException.class, () -> engine.init(app));
+
+ assertEquals("No template engines registered", ex.getMessage());
+ }
+
+ // --- Supports Tests ---
+
+ @Test
+ void shouldSupportHtmxModelAndView() {
+ HtmxModelAndView> htmxView = mock(HtmxModelAndView.class);
+ assertTrue(engine.supports(htmxView));
+ }
+
+ @Test
+ void shouldNotSupportStandardModelAndView() {
+ ModelAndView> standardView = ModelAndView.of("view.hbs", null);
+ assertFalse(engine.supports(standardView));
+ }
+
+ // --- Render Tests ---
+
+ @Test
+ void shouldReturnNullForStandardModelAndView() throws Exception {
+ ModelAndView> standardView = ModelAndView.of("view.hbs", null);
+ assertNull(engine.render(ctx, standardView));
+ }
+
+ @Test
+ void shouldThrowIllegalStateExceptionWhenNoTemplateEngineFound() {
+ // 1. Setup incompatible engine and initialize the HtmxTemplateEngine
+ TemplateEngine incompatibleEngine = mock(TemplateEngine.class);
+ when(router.getTemplateEngines()).thenReturn(Arrays.asList(engine, incompatibleEngine));
+ engine.init(app); // Cache the engines
+
+ // 2. Setup the HTMX view
+ HtmxModelAndView> htmxView = mock(HtmxModelAndView.class);
+ when(htmxView.getView()).thenReturn("missing.hbs");
+ when(incompatibleEngine.supports(htmxView)).thenReturn(false);
+
+ // 3. Execute and verify
+ IllegalStateException ex =
+ assertThrows(IllegalStateException.class, () -> engine.render(ctx, htmxView));
+
+ assertEquals("No template engine registered to handle: missing.hbs", ex.getMessage());
+ }
+
+ @Test
+ void shouldRenderMultipleTemplatesIntoCompositeOutput() throws Exception {
+ // 1. Mock the HtmxModelAndView and its iterator (simulating multiple OOB views)
+ HtmxModelAndView> htmxView = mock(HtmxModelAndView.class);
+ ModelAndView primaryView = ModelAndView.of("main.hbs", null);
+ ModelAndView oobView = ModelAndView.of("oob.hbs", null);
+ List views = Arrays.asList(primaryView, oobView);
+
+ when(htmxView.iterator()).thenReturn(views.iterator());
+
+ // 2. Mock Delegate Engines
+ TemplateEngine delegateEngine = mock(TemplateEngine.class);
+ when(delegateEngine.supports(htmxView)).thenReturn(true);
+
+ TemplateEngine incompatibleEngine = mock(TemplateEngine.class);
+ when(incompatibleEngine.supports(htmxView)).thenReturn(false);
+
+ // Register and initialize engines (HtmxTemplateEngine should remove itself from the cached
+ // list)
+ when(router.getTemplateEngines())
+ .thenReturn(Arrays.asList(engine, incompatibleEngine, delegateEngine));
+ engine.init(app);
+
+ // 3. Mock the Output Pipeline
+ OutputFactory outputFactory = mock(OutputFactory.class);
+ when(ctx.getOutputFactory()).thenReturn(outputFactory);
+
+ BufferedOutput composite = mock(BufferedOutput.class);
+ when(outputFactory.newComposite()).thenReturn(composite);
+
+ // Primary View Output
+ Output primaryOutput = mock(Output.class);
+ ByteBuffer primaryBuffer = ByteBuffer.wrap("primary".getBytes());
+ when(primaryOutput.asByteBuffer()).thenReturn(primaryBuffer);
+ when(delegateEngine.encode(ctx, primaryView)).thenReturn(primaryOutput);
+
+ // OOB View Output
+ Output oobOutput = mock(Output.class);
+ ByteBuffer oobBuffer = ByteBuffer.wrap("oob".getBytes());
+ when(oobOutput.asByteBuffer()).thenReturn(oobBuffer);
+ when(delegateEngine.encode(ctx, oobView)).thenReturn(oobOutput);
+
+ // 4. Execute
+ Output result = engine.render(ctx, htmxView);
+
+ // 5. Verify
+ assertSame(composite, result, "Should return the composite output builder");
+
+ // Verify that the byte buffers were written to the composite sequentially
+ verify(composite).write(primaryBuffer);
+ verify(composite).write(oobBuffer);
+ }
+}
diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HxTriggerTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HxTriggerTest.java
new file mode 100644
index 0000000000..5235740e5a
--- /dev/null
+++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HxTriggerTest.java
@@ -0,0 +1,24 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.htmx;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.EnumSet;
+
+import org.junit.jupiter.api.Test;
+
+import io.jooby.annotation.htmx.HxTrigger;
+
+public class HxTriggerTest {
+
+ @Test
+ void makeHappyEnumCoverage() {
+ assertTrue(EnumSet.allOf(HxTrigger.Phase.class).contains(HxTrigger.Phase.TRIGGER));
+ assertTrue(EnumSet.allOf(HxTrigger.Phase.class).contains(HxTrigger.Phase.AFTER_SETTLE));
+ assertTrue(EnumSet.allOf(HxTrigger.Phase.class).contains(HxTrigger.Phase.AFTER_SWAP));
+ }
+}
diff --git a/modules/jooby-jte/src/main/java/io/jooby/jte/JteModule.java b/modules/jooby-jte/src/main/java/io/jooby/jte/JteModule.java
index c5d1bd5ad0..83e7f9c18d 100644
--- a/modules/jooby-jte/src/main/java/io/jooby/jte/JteModule.java
+++ b/modules/jooby-jte/src/main/java/io/jooby/jte/JteModule.java
@@ -87,7 +87,9 @@ public void install(Jooby application) {
ServiceRegistry services = application.getServices();
services.put(TemplateEngine.class, templateEngine);
// model and view
- application.encoder(MediaType.html, new JteTemplateEngine(templateEngine));
+ var jteTemplateEngine = new JteTemplateEngine(templateEngine);
+ application.encoder(MediaType.html, jteTemplateEngine);
+ services.listOf(io.jooby.TemplateEngine.class).add(jteTemplateEngine);
// jte models
application.encoder(new JteModelEncoder());
}
diff --git a/modules/jooby-pebble/src/main/java/io/jooby/pebble/PebbleModule.java b/modules/jooby-pebble/src/main/java/io/jooby/pebble/PebbleModule.java
index 2f513aee66..52451319d4 100644
--- a/modules/jooby-pebble/src/main/java/io/jooby/pebble/PebbleModule.java
+++ b/modules/jooby-pebble/src/main/java/io/jooby/pebble/PebbleModule.java
@@ -15,6 +15,8 @@
import java.util.Locale;
import java.util.concurrent.ExecutorService;
+import org.jspecify.annotations.Nullable;
+
import com.typesafe.config.Config;
import io.jooby.Environment;
import io.jooby.Extension;
@@ -253,7 +255,7 @@ private static String stripLeadingSlash(String value) {
private static final List EXT = asList(".peb", ".pebble", ".html");
- private PebbleEngine.Builder builder;
+ private PebbleEngine.@Nullable Builder builder;
private String templatesPath;
@@ -286,7 +288,8 @@ public void install(Jooby application) throws Exception {
if (builder == null) {
builder = create().setTemplatesPath(templatesPath).build(application.getEnvironment());
}
- application.encoder(new PebbleTemplateEngine(builder, EXT));
+ var templateEngine = new PebbleTemplateEngine(builder, EXT);
+ application.encoder(templateEngine);
ServiceRegistry services = application.getServices();
services.put(PebbleEngine.Builder.class, builder);
diff --git a/modules/jooby-pebble/src/test/java/io/jooby/pebble/PebbleModuleTest.java b/modules/jooby-pebble/src/test/java/io/jooby/pebble/PebbleModuleTest.java
index a5f8b28972..9f48d311cd 100644
--- a/modules/jooby-pebble/src/test/java/io/jooby/pebble/PebbleModuleTest.java
+++ b/modules/jooby-pebble/src/test/java/io/jooby/pebble/PebbleModuleTest.java
@@ -31,6 +31,7 @@
import io.jooby.Jooby;
import io.jooby.ModelAndView;
import io.jooby.ServiceRegistry;
+import io.jooby.TemplateEngine;
import io.jooby.output.Output;
import io.jooby.test.MockContext;
import io.pebbletemplates.pebble.PebbleEngine;
@@ -141,14 +142,18 @@ public void renderWithLocale() throws Exception {
// --- Branch and Line Coverage Tests ---
@Test
+ @SuppressWarnings("unchecked")
public void installDefault() throws Exception {
Jooby app = mock(Jooby.class);
Environment env = mock(Environment.class);
Config config = mock(Config.class);
ServiceRegistry registry = mock(ServiceRegistry.class);
+ ServiceRegistry.MultiBinder enginesBinder =
+ mock(ServiceRegistry.MultiBinder.class);
when(app.getEnvironment()).thenReturn(env);
when(app.getServices()).thenReturn(registry);
+ when(registry.listOf(TemplateEngine.class)).thenReturn(enginesBinder);
when(env.getConfig()).thenReturn(config);
when(env.isActive("dev", "test")).thenReturn(false);
@@ -160,10 +165,15 @@ public void installDefault() throws Exception {
}
@Test
+ @SuppressWarnings("unchecked")
public void installCustomBuilderConstructor() throws Exception {
Jooby app = mock(Jooby.class);
ServiceRegistry registry = mock(ServiceRegistry.class);
+ ServiceRegistry.MultiBinder enginesBinder =
+ mock(ServiceRegistry.MultiBinder.class);
+
when(app.getServices()).thenReturn(registry);
+ when(registry.listOf(TemplateEngine.class)).thenReturn(enginesBinder);
PebbleEngine.Builder engineBuilder = new PebbleEngine.Builder();
PebbleModule module = new PebbleModule(engineBuilder);
diff --git a/modules/jooby-thymeleaf/src/main/java/io/jooby/thymeleaf/ThymeleafModule.java b/modules/jooby-thymeleaf/src/main/java/io/jooby/thymeleaf/ThymeleafModule.java
index a0c286e56c..367ffe696c 100644
--- a/modules/jooby-thymeleaf/src/main/java/io/jooby/thymeleaf/ThymeleafModule.java
+++ b/modules/jooby-thymeleaf/src/main/java/io/jooby/thymeleaf/ThymeleafModule.java
@@ -262,10 +262,12 @@ public void install(Jooby application) {
.build(application.getEnvironment());
}
- application.encoder(new ThymeleafTemplateEngine(templateEngine, EXT));
+ var thymeleafTE = new ThymeleafTemplateEngine(templateEngine, EXT);
+ application.encoder(thymeleafTE);
ServiceRegistry services = application.getServices();
services.put(TemplateEngine.class, templateEngine);
+ services.listOf(io.jooby.TemplateEngine.class).add(thymeleafTE);
}
/**
diff --git a/modules/jooby-thymeleaf/src/test/java/io/jooby/thymeleaf/ThymeleafModuleTest.java b/modules/jooby-thymeleaf/src/test/java/io/jooby/thymeleaf/ThymeleafModuleTest.java
index 6ece463e85..ea22f1a288 100644
--- a/modules/jooby-thymeleaf/src/test/java/io/jooby/thymeleaf/ThymeleafModuleTest.java
+++ b/modules/jooby-thymeleaf/src/test/java/io/jooby/thymeleaf/ThymeleafModuleTest.java
@@ -45,10 +45,16 @@ class ThymeleafModuleTest {
@Mock Environment env;
@Mock ServiceRegistry registry;
+ private ServiceRegistry.MultiBinder enginesBinder;
+
@BeforeEach
+ @SuppressWarnings("unchecked")
void setup() {
+ enginesBinder = mock(ServiceRegistry.MultiBinder.class);
+
lenient().when(app.getEnvironment()).thenReturn(env);
lenient().when(app.getServices()).thenReturn(registry);
+ lenient().when(registry.listOf(io.jooby.TemplateEngine.class)).thenReturn(enginesBinder);
// Make getProperty pass through the provided default value to simulate standard behavior
lenient()
@@ -69,6 +75,7 @@ void testDefaultConstructorInstall() {
verify(app).encoder(any(ThymeleafTemplateEngine.class));
verify(registry).put(eq(TemplateEngine.class), any(TemplateEngine.class));
+ verify(enginesBinder).add(any(io.jooby.TemplateEngine.class));
}
@Test
@@ -79,6 +86,7 @@ void testStringPathConstructorInstall() {
verify(app).encoder(any(ThymeleafTemplateEngine.class));
verify(registry).put(eq(TemplateEngine.class), any(TemplateEngine.class));
+ verify(enginesBinder).add(any(io.jooby.TemplateEngine.class));
}
@Test
@@ -89,6 +97,7 @@ void testPathObjectConstructorInstall(@TempDir Path tempDir) {
verify(app).encoder(any(ThymeleafTemplateEngine.class));
verify(registry).put(eq(TemplateEngine.class), any(TemplateEngine.class));
+ verify(enginesBinder).add(any(io.jooby.TemplateEngine.class));
}
@Test
@@ -103,6 +112,7 @@ void testTemplateEngineConstructorInstall() {
// Verify it registered the exact instance provided
verify(registry).put(TemplateEngine.class, mockEngine);
+ verify(enginesBinder).add(any(io.jooby.TemplateEngine.class));
}
// --- BUILDER CONFIGURATION TESTS ---
diff --git a/modules/pom.xml b/modules/pom.xml
index adfd2c5586..d0e18b7762 100644
--- a/modules/pom.xml
+++ b/modules/pom.xml
@@ -82,6 +82,7 @@
jooby-pebble
jooby-rocker
jooby-thymeleaf
+ jooby-htmx
jooby-camel
diff --git a/tests/pom.xml b/tests/pom.xml
index 31d9c572eb..307b25094b 100644
--- a/tests/pom.xml
+++ b/tests/pom.xml
@@ -182,6 +182,12 @@
${jooby.version}
+