Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions docs/asciidoc/modules/htmx.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
== HTMX

https://htmx.org[HTMX] first-class support for Jooby.

The HTMX module provides a seamless bridge between modern, reactive Single Page Application (SPA) mechanics and traditional server-side rendering. It offers both a memory-safe Imperative Builder and a powerful Declarative Annotation API (via APT) to orchestrate HTMX responses without repetitive boilerplate.

*Note:* `HtmxTemplateEngine` acts as a composite delegator. You must also install a backing template engine (like Handlebars, Freemarker, or Pebble) to actually render the views.

=== Usage

1) Add the dependencies (HTMX and your preferred template engine):

[dependency, artifactId="jooby-htmx, jooby-handlebars:Handlebars Module"]
.

2) Write your templates inside the `views` folder. Notice how the layout dynamically embeds the requested partial using `childView`.

.views/layout.hbs
[source, html]
----
<!DOCTYPE html>
<html>
<body>
<nav>My App</nav>
<main>
{{> (lookup childView) }}
</main>
</body>
</html>
----

.views/tasks.hbs
[source, html]
----
<ul id="task-list">
{{#each tasks}}
<li>{{title}}</li>
{{/each}}
</ul>
----

3) Install the module and write your controller.

.Java
[source, java, role="primary"]
----
import io.jooby.htmx.HtmxModule;
import io.jooby.handlebars.HandlebarsModule;
import io.jooby.annotation.htmx.HxView;

{
install(new HandlebarsModule()); <1>
install(new HtmxModule()); <2>

mvc(new TaskUIHtmx_()); <3>
}

public class TaskUI {

@GET("/tasks")
@HxView(value = "tasks.hbs", layout = "layout.hbs")
public Map<String, Object> getTasks() {
return Map.of("tasks", List.of(new Task("Buy milk")));
}
}
----

.Kotlin
[source, kt, role="secondary"]
----
import io.jooby.htmx.HtmxModule
import io.jooby.handlebars.HandlebarsModule
import io.jooby.annotation.htmx.HxView

{
install(HandlebarsModule()) <1>
install(HtmxModule()) <2>

mvc(TaskUIHtmx_()) <3>
}

class TaskUI {

@GET("/tasks")
@HxView(value = "tasks.hbs", layout = "layout.hbs")
fun getTasks(): Map<String, Any> {
return mapOf("tasks" to listOf(Task("Buy milk")))
}
}
----

<1> Install your base template engine
<2> Install the HTMX engine
<3> Add generated `Htmx_` controller

=== The SPA Shell Layout Engine

The `@HxView` annotation implements a secure, Fail-Fast Guard Clause for layout management.

When you define a `layout` attribute, the framework intelligently checks the origin of the request:

* **HTMX AJAX Requests:** The layout is ignored. The framework responds only with the fast, targeted partial view (`tasks.hbs`).
* **Direct Browser Requests (F5 / Bookmarks):** The framework intercepts the request, blocks the raw fragment from rendering, and automatically injects the partial inside your defined `layout.hbs` (passed as the `childView` attribute).

If a method returns a dynamic HTMX fragment but *lacks* a layout, direct browser access is automatically blocked via a `406 Not Acceptable` exception.

=== Declarative API (Annotations)

When using Jooby's MVC routes, you can orchestrate complex UI state entirely through annotations:

.Java
[source, java]
----
@POST("/tasks")
@HxView("task_row.hbs")
@HxOob("task_counter.hbs") // Automatically appends an Out-Of-Band swap
@HxTrigger("taskAdded") // Triggers a client-side JS event
@HxError("task_error.hbs") // Scoped Error Handler: Catches validation errors
public Task addTask(@Valid TaskDto dto) {
return db.save(dto);
}
----

==== Scoped Error Handling & Validation
The `@HxError` annotation acts as a "UI Janitor" for **Scoped Errors** (such as HTTP 400 Bad Request or 422 Unprocessable Entity). If Bean Validation fails, it catches the exception and renders your targeted error template.

* **Validation Integration:** The model passed to your error template automatically includes a `validationResult` object that perfectly follows the `io.jooby.validation.ValidationResult` format. This allows seamless integration with Jooby's Jakarta validation modules (`hibernate-validator` or `avaje-validator`).
* **Auto-Clearing:** Crucially, on a *successful* request, the framework automatically appends an empty OOB swap for the error template, instantly clearing the UI of any previous error messages.

=== Imperative API (HtmxResponse)

For scenarios lacking a primary view (like a `DELETE` operation), use the fluent `HtmxResponse` builder to explicitly chain events, headers, and OOB updates.

.Java
[source, java, role="primary"]
----
@DELETE("/tasks/{id}")
public HtmxResponse deleteTask(@PathParam String id) {
db.delete(id);

return HtmxResponse.empty()
.addOob("task_counter.hbs", Map.of("activeCount", db.getActiveCount()))
.triggerAfterSettle("showToast", Map.of("message", "Task deleted!"));
}
----

.Kotlin
[source, kt, role="secondary"]
----
@DELETE("/tasks/{id}")
fun deleteTask(@PathParam id: String): HtmxResponse {
db.delete(id)

return HtmxResponse.empty()
.addOob("task_counter.hbs", mapOf("activeCount" to db.getActiveCount()))
.triggerAfterSettle("showToast", mapOf("message" to "Task deleted!"))
}
----

=== Global Error Handling

While `@HxError` handles scoped validation, you can seamlessly convert **Global Application Errors** (like 500 Server Crashes) into graceful HTMX responses (like OOB toast notifications) by passing a custom `HtmxErrorHandler` to the module during installation.

**Smart Interception:** This global handler is highly intelligent. It *only* intercepts requests that contain the `HX-Request: true` header. If a standard browser request crashes (e.g., a normal page load or hitting F5), this handler is safely bypassed, and the default Jooby global application error handler takes over to display a standard error page.

.Java
[source, java, role="primary"]
----
import io.jooby.htmx.HtmxModule;

{
install(new HtmxModule((ctx, cause, code) -> {
// Convert the crash into a safe UI notification without breaking the DOM
return HtmxResponse.empty(code)
.addOob("toast.hbs", Map.of("error", cause.getMessage()));
}));
}
----

.Kotlin
[source, kt, role="secondary"]
----
import io.jooby.htmx.HtmxModule

{
install(HtmxModule { ctx, cause, code ->
HtmxResponse.empty(code)
.addOob("toast.hbs", mapOf("error" to cause.message))
})
}
----
3 changes: 2 additions & 1 deletion docs/asciidoc/modules/modules.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,11 @@ Modules are distributed as separate dependencies. Below is the catalog of offici
* link:{uiVersion}/modules/openapi[OpenAPI]: OpenAPI supports.

==== Template Engine
* link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine.
* link:{uiVersion}/modules/handlebars[Handlebars]: Handlebars template engine.
* link:{uiVersion}/modules/htmx[HTMX]: First-class HTMX support with declarative annotations and SPA layout management.
* link:{uiVersion}/modules/jstachio[JStachio]: JStachio template engine.
* link:{uiVersion}/modules/jte[jte]: jte template engine.
* link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine.
* link:{uiVersion}/modules/pebble[Pebble]: Pebble template engine.
* link:{uiVersion}/modules/rocker[Rocker]: Rocker template engine.
* link:{uiVersion}/modules/thymeleaf[Thymeleaf]: Thymeleaf template engine.
Expand Down
5 changes: 5 additions & 0 deletions jooby/src/main/java/io/jooby/Jooby.java
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,11 @@ public ServiceRegistry getServices() {
return this.router.getServices();
}

@Override
public List<TemplateEngine> getTemplateEngines() {
return this.router.getTemplateEngines();
}

/**
* Get base application package. This is the package from where application was initialized or the
* package of a Jooby application sub-class.
Expand Down
7 changes: 4 additions & 3 deletions jooby/src/main/java/io/jooby/ModelAndView.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,13 @@ public static MapModelAndView map(String view, Map<String, Object> model) {
* any other object.
* @return A {@code ModelAndView} instance corresponding to the specified view and model.
*/
public static ModelAndView<Map<String, Object>> of(String view, Object model) {
@SuppressWarnings({"unchecked", "rawtypes"})
public static <T> ModelAndView<T> of(String view, @Nullable Object model) {
if (model == null) {
return map(view);
return (ModelAndView<T>) map(view);
}
if (model instanceof Map mapModel) {
return map(view, mapModel);
return (ModelAndView<T>) map(view, mapModel);
}
return new ModelAndView(view, model);
}
Expand Down
7 changes: 7 additions & 0 deletions jooby/src/main/java/io/jooby/Router.java
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,13 @@ default Executor executor(String name) {
*/
ValueFactory getValueFactory();

/**
* Retrieves a list of available template engines.
*
* @return a list of TemplateEngine objects representing the available template engines.
*/
List<TemplateEngine> getTemplateEngines();

/**
* Set value factory, useful for custom value factory.
*
Expand Down
2 changes: 2 additions & 0 deletions jooby/src/main/java/io/jooby/TemplateEngine.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* @author edgar
*/
public interface TemplateEngine extends MessageEncoder {
/** Just a template engine that is on top of the stack (run before all other engines). */
interface OnTop extends TemplateEngine {}

/** Name of application property that defines the template path. */
String TEMPLATE_PATH = "templates.path";
Expand Down
11 changes: 10 additions & 1 deletion jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ public class HttpMessageEncoder implements MessageEncoder {
public HttpMessageEncoder add(MediaType type, MessageEncoder encoder) {
if (encoder instanceof TemplateEngine engine) {
// Media type is ignored for template engines. They have a custom object type
templateEngineList.add(engine);
if (engine instanceof TemplateEngine.OnTop) {
// need to go first
templateEngineList.addFirst(engine);
} else {
templateEngineList.add(engine);
}
} else {
if (encoders == null) {
encoders = new LinkedHashMap<>();
Expand Down Expand Up @@ -106,4 +111,8 @@ public Output encode(Context ctx, Object value) throws Exception {
return MessageEncoder.TO_STRING.encode(ctx, value);
}
}

public List<TemplateEngine> getTemplateEngines() {
return templateEngineList;
}
}
4 changes: 4 additions & 0 deletions jooby/src/main/java/io/jooby/internal/RouterImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,10 @@ public Router setCurrentUser(Function<Context, Object> provider) {
return this;
}

public List<TemplateEngine> getTemplateEngines() {
return Collections.unmodifiableList(encoder.getTemplateEngines());
}

@Override
public String toString() {
StringBuilder buff = new StringBuilder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public ValidationExceptionChain add(ValidationExceptionMapper mapper) {
// Assume is a client error, provide a default result
return new ValidationResult(
"Validation failed",
suggestedCode.value(),
StatusCode.UNPROCESSABLE_ENTITY.value(),
List.of(
new ValidationResult.Error(
null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ void shouldReturnDefaultResultWhenNoMapperHandlesItAndStatusCodeIsClientError()

assertNotNull(result);
assertEquals("Validation failed", result.getTitle());
assertEquals(400, result.getStatus());
assertEquals(422, result.getStatus());

assertEquals(1, result.getErrors().size());
ValidationResult.Error error = result.getErrors().get(0);
Expand All @@ -82,7 +82,7 @@ void shouldReturnDefaultResultUsingClassNameWhenExceptionHasNoMessage() {

assertNotNull(result);
assertEquals("Validation failed", result.getTitle());
assertEquals(400, result.getStatus());
assertEquals(422, result.getStatus());

assertEquals(1, result.getErrors().size());
ValidationResult.Error error = result.getErrors().get(0);
Expand Down
7 changes: 7 additions & 0 deletions modules/jooby-apt/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
<scope>test</scope>
</dependency>

<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-htmx</artifactId>
<version>${jooby.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-jsonrpc</artifactId>
Expand Down
36 changes: 30 additions & 6 deletions modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,20 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
context.getRouters().forEach(it -> context.debug(" %s", it.getGeneratedType()));
return false;
} else {
// Discover all unique Controller classes
var controllers = findControllers(annotations, roundEnv);

// Factory Pattern: Build specific routers for each class based on method annotations
List<WebRouter<?>> activeRouters = new ArrayList<>();

for (var controller : controllers) {
if (controller.getModifiers().contains(Modifier.ABSTRACT)) continue;

var restRouter = RestRouter.parse(context, controller);
if (!restRouter.isEmpty()) {
activeRouters.add(restRouter);
// --- PASS 1: Specialized Routers & Claim Gathering ---
Set<String> masterClaimedRoutes = new HashSet<>();

// Parse HTMX first to claim route paths
var htmxRouter = io.jooby.internal.apt.htmx.HtmxRouter.parse(context, controller);
if (!htmxRouter.isEmpty()) {
activeRouters.add(htmxRouter);
masterClaimedRoutes.addAll(htmxRouter.getClaimedRoutes());
}

var jsonRpcRouter = JsonRpcRouter.parse(context, controller);
Expand All @@ -162,6 +165,13 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
if (!wsRouter.isEmpty()) {
activeRouters.add(wsRouter);
}

// --- PASS 2: Standard Rest Router (Fallback) ---
// Pass the claimed routes to RestRouter so it knows what to skip
var restRouter = RestRouter.parse(context, controller, masterClaimedRoutes);
if (!restRouter.isEmpty()) {
activeRouters.add(restRouter);
}
}

verifyBeanValidationDependency(activeRouters);
Expand Down Expand Up @@ -288,6 +298,20 @@ public Set<String> getSupportedAnnotationTypes() {
supportedTypes.add("io.jooby.annotation.ws.OnClose");
supportedTypes.add("io.jooby.annotation.ws.OnMessage");
supportedTypes.add("io.jooby.annotation.ws.OnError");
// Add Htmx Annotations
supportedTypes.addAll(
Set.of(
"io.jooby.annotation.htmx.HxView",
"io.jooby.annotation.htmx.HxError",
"io.jooby.annotation.htmx.HxOob",
"io.jooby.annotation.htmx.HxOobs",
"io.jooby.annotation.htmx.HxPushUrl",
"io.jooby.annotation.htmx.HxRedirect",
"io.jooby.annotation.htmx.HxRefresh",
"io.jooby.annotation.htmx.HxSwap",
"io.jooby.annotation.htmx.HxTarget",
"io.jooby.annotation.htmx.HxTrigger",
"io.jooby.annotation.htmx.HxTriggers"));
return supportedTypes;
}

Expand Down
Loading
Loading