diff --git a/htmx-api/pom.xml b/htmx-api/pom.xml
new file mode 100644
index 000000000..ad4b7e30c
--- /dev/null
+++ b/htmx-api/pom.xml
@@ -0,0 +1,25 @@
+
+
+ 4.0.0
+
+ io.avaje
+ avaje-http-parent
+ 2.7
+
+
+ avaje-htmx-api
+
+
+
+
+
+
+ io.avaje
+ avaje-lang
+ 1.0
+
+
+
+
diff --git a/htmx-api/src/main/java/io/avaje/htmx/api/DHxRequest.java b/htmx-api/src/main/java/io/avaje/htmx/api/DHxRequest.java
new file mode 100644
index 000000000..c213b6e13
--- /dev/null
+++ b/htmx-api/src/main/java/io/avaje/htmx/api/DHxRequest.java
@@ -0,0 +1,141 @@
+package io.avaje.htmx.api;
+
+import io.avaje.lang.Nullable;
+
+final class DHxRequest implements HtmxRequest {
+
+ private final boolean htmxRequest;
+
+ private final boolean boosted;
+ private final String currentUrl;
+ private final boolean historyRestoreRequest;
+ private final String promptResponse;
+ private final String target;
+ private final String triggerName;
+ private final String triggerId;
+
+ DHxRequest() {
+ this.htmxRequest = false;
+ this.boosted = false;
+ this.currentUrl = null;
+ this.historyRestoreRequest = false;
+ this.promptResponse = null;
+ this.target = null;
+ this.triggerName = null;
+ this.triggerId = null;
+ }
+
+ DHxRequest(boolean boosted, String currentUrl, boolean historyRestoreRequest, String promptResponse, String target, String triggerName, String triggerId) {
+ this.htmxRequest = true;
+ this.boosted = boosted;
+ this.currentUrl = currentUrl;
+ this.historyRestoreRequest = historyRestoreRequest;
+ this.promptResponse = promptResponse;
+ this.target = target;
+ this.triggerName = triggerName;
+ this.triggerId = triggerId;
+ }
+
+ @Override
+ public boolean isHtmxRequest() {
+ return htmxRequest;
+ }
+
+ @Override
+ public boolean isBoosted() {
+ return boosted;
+ }
+
+ @Nullable
+ @Override
+ public String currentUrl() {
+ return currentUrl;
+ }
+
+ @Override
+ public boolean isHistoryRestoreRequest() {
+ return historyRestoreRequest;
+ }
+
+ @Nullable
+ @Override
+ public String promptResponse() {
+ return promptResponse;
+ }
+
+ @Nullable
+ @Override
+ public String target() {
+ return target;
+ }
+
+ @Nullable
+ @Override
+ public String triggerName() {
+ return triggerName;
+ }
+
+ @Nullable
+ public String triggerId() {
+ return triggerId;
+ }
+
+ static final class DBuilder implements Builder {
+
+ private boolean boosted;
+ private String currentUrl;
+ private boolean historyRestoreRequest;
+ private String promptResponse;
+ private String target;
+ private String triggerName;
+ private String triggerId;
+
+ @Override
+ public DBuilder boosted(boolean boosted) {
+ this.boosted = boosted;
+ return this;
+ }
+
+ @Override
+ public DBuilder currentUrl(String currentUrl) {
+ this.currentUrl = currentUrl;
+ return this;
+ }
+
+ @Override
+ public DBuilder historyRestoreRequest(boolean historyRestoreRequest) {
+ this.historyRestoreRequest = historyRestoreRequest;
+ return this;
+ }
+
+ @Override
+ public DBuilder promptResponse(String promptResponse) {
+ this.promptResponse = promptResponse;
+ return this;
+ }
+
+ @Override
+ public DBuilder target(String target) {
+ this.target = target;
+ return this;
+ }
+
+ @Override
+ public DBuilder triggerName(String triggerName) {
+ this.triggerName = triggerName;
+ return this;
+ }
+
+ @Override
+ public DBuilder triggerId(String triggerId) {
+ this.triggerId = triggerId;
+ return this;
+ }
+
+ @Override
+ public HtmxRequest build() {
+ return new DHxRequest(boosted, currentUrl, historyRestoreRequest, promptResponse, target, triggerName, triggerId);
+ }
+ }
+
+}
diff --git a/htmx-api/src/main/java/io/avaje/htmx/api/Html.java b/htmx-api/src/main/java/io/avaje/htmx/api/Html.java
new file mode 100644
index 000000000..bedae0ded
--- /dev/null
+++ b/htmx-api/src/main/java/io/avaje/htmx/api/Html.java
@@ -0,0 +1,18 @@
+package io.avaje.htmx.api;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+/**
+ * Mark a controller as producing HTML by default and using "Templating"
+ * meaning that response objects are expected to a "Model View" passed to
+ * the "Templating" library.
+ */
+@Target(TYPE)
+@Retention(RUNTIME)
+public @interface Html {
+
+}
diff --git a/htmx-api/src/main/java/io/avaje/htmx/api/HtmxRequest.java b/htmx-api/src/main/java/io/avaje/htmx/api/HtmxRequest.java
new file mode 100644
index 000000000..d8a47a22c
--- /dev/null
+++ b/htmx-api/src/main/java/io/avaje/htmx/api/HtmxRequest.java
@@ -0,0 +1,106 @@
+package io.avaje.htmx.api;
+
+import io.avaje.lang.Nullable;
+
+/**
+ * This class can be used as a controller method argument to access
+ * the htmx Request Headers .
+ *
+ *
{@code
+ *
+ * @HxRequest
+ * @Get("/users")
+ * String users(HtmxRequest htmxRequest) {
+ * if (htmxRequest.isBoosted()) {
+ * ...
+ * }
+ * }
+ *
+ * }
+ *
+ * @see Request Headers Reference
+ */
+public interface HtmxRequest {
+
+ /**
+ * Represents a non-Htmx request.
+ */
+ HtmxRequest EMPTY = new DHxRequest();
+
+ /**
+ * Return a new builder for the HtmxRequest.
+ */
+ static Builder builder() {
+ return new DHxRequest.DBuilder();
+ }
+
+ /**
+ * Return true if this is an Htmx request.
+ */
+ boolean isHtmxRequest();
+
+ /**
+ * Indicates that the request is via an element using hx-boost.
+ *
+ * @return true if the request was made via HX-Boost, false otherwise
+ */
+ boolean isBoosted();
+
+ /**
+ * Return the current URL of the browser when the htmx request was made.
+ */
+ @Nullable
+ String currentUrl();
+
+ /**
+ * Indicates if the request is for history restoration after a miss in the local history cache
+ *
+ * @return true if this request is for history restoration, false otherwise
+ */
+ boolean isHistoryRestoreRequest();
+
+ /**
+ * Return the user response to an HX-Prompt.
+ */
+ @Nullable
+ String promptResponse();
+
+ /**
+ * Return the id of the target element if it exists.
+ */
+ @Nullable
+ String target();
+
+ /**
+ * Return the name of the triggered element if it exists.
+ */
+ @Nullable
+ String triggerName();
+
+ /**
+ * Return the id of the triggered element if it exists.
+ */
+ @Nullable
+ String triggerId();
+
+ /**
+ * Builder for {@link HtmxRequest}.
+ */
+ interface Builder {
+ Builder boosted(boolean boosted);
+
+ Builder currentUrl(String currentUrl);
+
+ Builder historyRestoreRequest(boolean historyRestoreRequest);
+
+ Builder promptResponse(String promptResponse);
+
+ Builder target(String target);
+
+ Builder triggerName(String triggerName);
+
+ Builder triggerId(String triggerId);
+
+ HtmxRequest build();
+ }
+}
diff --git a/htmx-api/src/main/java/io/avaje/htmx/api/HxRequest.java b/htmx-api/src/main/java/io/avaje/htmx/api/HxRequest.java
new file mode 100644
index 000000000..e643f31e5
--- /dev/null
+++ b/htmx-api/src/main/java/io/avaje/htmx/api/HxRequest.java
@@ -0,0 +1,54 @@
+package io.avaje.htmx.api;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+/**
+ * Mark a controller method as handling Htmx requests and potentially restrict
+ * the handler to only be used for specific Htmx target or Htmx trigger.
+ *
+ * Controller methods with {@code @HxRequest} require the {@code HX-Request}
+ * HTTP Header to be set for the handler to process the request. Additionally,
+ * we can specify {@link #target()}, {@link #triggerId()}, or {@link #triggerName()}
+ * such that the handler is only invoked specifically for requests with those
+ * matching headers.
+ */
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+public @interface HxRequest {
+
+ /**
+ * Restricts the mapping to the {@code id} of a specific target element.
+ *
+ * @see HX-Target
+ */
+ String target() default "";
+
+ /**
+ * Restricts the mapping to the {@code id} of a specific triggered element.
+ *
+ * @see HX-Trigger
+ */
+ String triggerId() default "";
+
+ /**
+ * Restricts the mapping to the {@code name} of a specific triggered element.
+ *
+ * @see HX-Trigger-Name
+ */
+ String triggerName() default "";
+
+ /**
+ * Restricts the mapping to the {@code id}, if any, or to the {@code name} of a specific triggered element.
+ *
+ * If you want to be explicit use {@link #triggerId()} or {@link #triggerName()}.
+ *
+ * @see HX-Trigger
+ * @see HX-Trigger-Name
+ */
+ String value() default "";
+}
diff --git a/htmx-api/src/main/java/module-info.java b/htmx-api/src/main/java/module-info.java
new file mode 100644
index 000000000..c018025a9
--- /dev/null
+++ b/htmx-api/src/main/java/module-info.java
@@ -0,0 +1,6 @@
+module io.avaje.htmx.api {
+
+ exports io.avaje.htmx.api;
+
+ requires static io.avaje.lang;
+}
diff --git a/htmx-nima-jstache/pom.xml b/htmx-nima-jstache/pom.xml
new file mode 100644
index 000000000..3305f71c0
--- /dev/null
+++ b/htmx-nima-jstache/pom.xml
@@ -0,0 +1,47 @@
+
+
+ 4.0.0
+
+ io.avaje
+ avaje-http-parent
+ 2.7
+
+
+ avaje-htmx-nima-jstache
+
+
+ 21
+ 21
+ 21
+ UTF-8
+ false
+ 1.3.5
+
+
+
+
+ io.jstach
+ jstachio
+ ${io.jstach.version}
+
+
+ io.avaje
+ avaje-htmx-api
+ ${project.version}
+
+
+ io.avaje
+ avaje-htmx-nima
+ ${project.version}
+
+
+ io.avaje
+ avaje-inject
+ 10.3
+ provided
+ true
+
+
+
diff --git a/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/DefaultTemplateProvider.java b/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/DefaultTemplateProvider.java
new file mode 100644
index 000000000..342e2bd45
--- /dev/null
+++ b/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/DefaultTemplateProvider.java
@@ -0,0 +1,21 @@
+package io.avaje.htmx.nima.jstache;
+
+import io.avaje.htmx.nima.TemplateRender;
+import io.avaje.inject.BeanScopeBuilder;
+import io.avaje.inject.spi.Plugin;
+
+/**
+ * Plugin for avaje inject that provides a default TemplateRender instance.
+ */
+public final class DefaultTemplateProvider implements Plugin {
+
+ @Override
+ public Class>[] provides() {
+ return new Class>[]{TemplateRender.class};
+ }
+
+ @Override
+ public void apply(BeanScopeBuilder builder) {
+ builder.provideDefault(null, TemplateRender.class, JStacheTemplateRender::new);
+ }
+}
diff --git a/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/JStacheTemplateRender.java b/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/JStacheTemplateRender.java
new file mode 100644
index 000000000..beb4bff60
--- /dev/null
+++ b/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/JStacheTemplateRender.java
@@ -0,0 +1,15 @@
+package io.avaje.htmx.nima.jstache;
+
+import io.avaje.htmx.nima.TemplateRender;
+import io.helidon.webserver.http.ServerRequest;
+import io.helidon.webserver.http.ServerResponse;
+import io.jstach.jstachio.JStachio;
+
+public final class JStacheTemplateRender implements TemplateRender {
+
+ @Override
+ public void render(Object viewModel, ServerRequest req, ServerResponse res) {
+ var content = JStachio.render(viewModel);
+ res.send(content);
+ }
+}
diff --git a/htmx-nima-jstache/src/main/java/module-info.java b/htmx-nima-jstache/src/main/java/module-info.java
new file mode 100644
index 000000000..2da4668b1
--- /dev/null
+++ b/htmx-nima-jstache/src/main/java/module-info.java
@@ -0,0 +1,11 @@
+module io.avaje.htmx.nima.jstache {
+
+ exports io.avaje.htmx.nima.jstache;
+
+ requires transitive io.avaje.htmx.nima;
+ requires transitive io.helidon.webserver;
+ requires transitive io.jstach.jstachio;
+ requires io.avaje.inject;
+
+ provides io.avaje.inject.spi.Plugin with io.avaje.htmx.nima.jstache.DefaultTemplateProvider;
+}
diff --git a/htmx-nima-jstache/src/main/resources/META-INF/services/io.avaje.inject.spi.Plugin b/htmx-nima-jstache/src/main/resources/META-INF/services/io.avaje.inject.spi.Plugin
new file mode 100644
index 000000000..456aa71f2
--- /dev/null
+++ b/htmx-nima-jstache/src/main/resources/META-INF/services/io.avaje.inject.spi.Plugin
@@ -0,0 +1 @@
+io.avaje.htmx.nima.jstache.DefaultTemplateProvider
diff --git a/htmx-nima/pom.xml b/htmx-nima/pom.xml
new file mode 100644
index 000000000..bc405cfec
--- /dev/null
+++ b/htmx-nima/pom.xml
@@ -0,0 +1,32 @@
+
+
+ 4.0.0
+
+ io.avaje
+ avaje-http-parent
+ 2.7
+
+
+ avaje-htmx-nima
+
+
+ 21
+ false
+ UTF-8
+
+
+
+
+ io.avaje
+ avaje-htmx-api
+ 2.7
+
+
+ io.helidon.webserver
+ helidon-webserver
+ 4.0.7
+
+
+
diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandler.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandler.java
new file mode 100644
index 000000000..6b44530a1
--- /dev/null
+++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandler.java
@@ -0,0 +1,50 @@
+package io.avaje.htmx.nima;
+
+import io.helidon.http.Header;
+import io.helidon.http.ServerRequestHeaders;
+import io.helidon.webserver.http.Handler;
+import io.helidon.webserver.http.ServerRequest;
+import io.helidon.webserver.http.ServerResponse;
+
+import static io.avaje.htmx.nima.HxHeaders.*;
+
+final class DHxHandler implements Handler {
+
+
+ private final Handler delegate;
+ private final String target;
+ private final String trigger;
+ private final String triggerName;
+
+ DHxHandler(Handler delegate, String target, String trigger, String triggerName) {
+ this.delegate = delegate;
+ this.target = target;
+ this.trigger = trigger;
+ this.triggerName = triggerName;
+ }
+
+ @Override
+ public void handle(ServerRequest req, ServerResponse res) throws Exception {
+ final var headers = req.headers();
+ if (headers.contains(HX_REQUEST) && matched(headers)) {
+ delegate.handle(req, res);
+ } else {
+ res.next();
+ }
+ }
+
+ private boolean matched(ServerRequestHeaders headers) {
+ if (target != null && notMatched(headers.get(HX_TARGET), target)) {
+ return false;
+ }
+ if (trigger != null && notMatched(headers.get(HX_TRIGGER), trigger)) {
+ return false;
+ }
+ return triggerName == null || !notMatched(headers.get(HX_TRIGGER_NAME), triggerName);
+ }
+
+ private boolean notMatched(Header header, String matchValue) {
+ return header == null || !matchValue.equals(header.get());
+ }
+
+}
diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandlerBuilder.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandlerBuilder.java
new file mode 100644
index 000000000..b9370074c
--- /dev/null
+++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandlerBuilder.java
@@ -0,0 +1,38 @@
+package io.avaje.htmx.nima;
+
+import io.helidon.webserver.http.Handler;
+
+final class DHxHandlerBuilder implements HxHandler.Builder {
+
+ private final Handler delegate;
+ private String target;
+ private String trigger;
+ private String triggerName;
+
+ DHxHandlerBuilder(Handler delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public DHxHandlerBuilder target(String target) {
+ this.target = target;
+ return this;
+ }
+
+ @Override
+ public DHxHandlerBuilder trigger(String trigger) {
+ this.trigger = trigger;
+ return this;
+ }
+
+ @Override
+ public DHxHandlerBuilder triggerName(String triggerName) {
+ this.triggerName = triggerName;
+ return this;
+ }
+
+ @Override
+ public Handler build() {
+ return new DHxHandler(delegate, target, trigger, triggerName);
+ }
+}
diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHandler.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHandler.java
new file mode 100644
index 000000000..0c28dbbd5
--- /dev/null
+++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHandler.java
@@ -0,0 +1,46 @@
+package io.avaje.htmx.nima;
+
+import io.helidon.webserver.http.Handler;
+
+/**
+ * Wrap a Handler with filtering for Htmx specific headers.
+ *
+ * The underlying Handler will not be invoked unless the request
+ * is a Htmx request and matches the required attributes.
+ */
+public interface HxHandler {
+
+ /**
+ * Create a builder that wraps the underlying handler with Htmx
+ * specific attribute matching.
+ */
+ static Builder builder(Handler delegate) {
+ return new DHxHandlerBuilder(delegate);
+ }
+
+ /**
+ * Build the Htmx request handler.
+ */
+ interface Builder {
+
+ /**
+ * Match on the given target.
+ */
+ Builder target(String target);
+
+ /**
+ * Match on the given trigger.
+ */
+ Builder trigger(String trigger);
+
+ /**
+ * Match on the given trigger name.
+ */
+ Builder triggerName(String triggerName);
+
+ /**
+ * Build and return the Handler.
+ */
+ Handler build();
+ }
+}
diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHeaders.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHeaders.java
new file mode 100644
index 000000000..156798d29
--- /dev/null
+++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHeaders.java
@@ -0,0 +1,65 @@
+package io.avaje.htmx.nima;
+
+import io.helidon.http.HeaderName;
+import io.helidon.http.HeaderNames;
+
+/**
+ * HTMX request headers.
+ *
+ * @see Request Headers Reference
+ */
+public interface HxHeaders {
+
+ /**
+ * Indicates that the request comes from an element that uses hx-boost.
+ *
+ * @see HX-Boosted
+ */
+ HeaderName HX_BOOSTED = HeaderNames.create("HX-Boosted");
+
+ /**
+ * The current URL of the browser
+ *
+ * @see HX-Current-URL
+ */
+ HeaderName HX_CURRENT_URL = HeaderNames.create("HX-Current-URL");
+
+ /**
+ * Indicates if the request is for history restoration after a miss in the local history cache.
+ *
+ * @see HX-History-Restore-Request
+ */
+ HeaderName HX_HISTORY_RESTORE_REQUEST = HeaderNames.create("HX-History-Restore-Request");
+
+ /**
+ * Contains the user response to a hx-prompt .
+ *
+ * @see HX-Prompt
+ */
+ HeaderName HX_PROMPT = HeaderNames.create("HX-Prompt");
+ /**
+ * Only present and {@code true} if the request is issued by htmx.
+ *
+ * @see HX-Request
+ */
+ HeaderName HX_REQUEST = HeaderNames.create("HX-Request");
+ /**
+ * The {@code id} of the target element if it exists.
+ *
+ * @see HX-Target
+ */
+ HeaderName HX_TARGET = HeaderNames.create("HX-Target");
+ /**
+ * The {@code name} of the triggered element if it exists
+ *
+ * @see HX-Trigger-Name
+ */
+ HeaderName HX_TRIGGER_NAME = HeaderNames.create("HX-Trigger-Name");
+ /**
+ * The {@code id} of the triggered element if it exists.
+ *
+ * @see HX-Trigger
+ */
+ HeaderName HX_TRIGGER = HeaderNames.create("HX-Trigger");
+
+}
diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/HxReq.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxReq.java
new file mode 100644
index 000000000..2dda4c675
--- /dev/null
+++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxReq.java
@@ -0,0 +1,49 @@
+package io.avaje.htmx.nima;
+
+import io.avaje.htmx.api.HtmxRequest;
+import io.helidon.webserver.http.ServerRequest;
+
+/**
+ * Obtain the HtmxRequest for the given Helidon ServerRequest.
+ */
+public class HxReq {
+
+ /**
+ * Create given the server request.
+ */
+ public static HtmxRequest of(ServerRequest request) {
+ final var headers = request.headers();
+ if (!headers.contains(HxHeaders.HX_REQUEST)) {
+ return HtmxRequest.EMPTY;
+ }
+
+ var builder = HtmxRequest.builder();
+ if (headers.contains(HxHeaders.HX_BOOSTED)) {
+ builder.boosted(true);
+ }
+ if (headers.contains(HxHeaders.HX_HISTORY_RESTORE_REQUEST)) {
+ builder.historyRestoreRequest(true);
+ }
+ var currentUrl = headers.get(HxHeaders.HX_CURRENT_URL);
+ if (currentUrl != null) {
+ builder.currentUrl(currentUrl.get());
+ }
+ var prompt = headers.get(HxHeaders.HX_PROMPT);
+ if (prompt != null) {
+ builder.promptResponse(prompt.get());
+ }
+ var target = headers.get(HxHeaders.HX_TARGET);
+ if (target != null) {
+ builder.target(target.get());
+ }
+ var triggerName = headers.get(HxHeaders.HX_TRIGGER_NAME);
+ if (triggerName != null) {
+ builder.triggerName(triggerName.get());
+ }
+ var trigger = headers.get(HxHeaders.HX_TRIGGER);
+ if (trigger != null) {
+ builder.triggerId(trigger.get());
+ }
+ return builder.build();
+ }
+}
diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/TemplateRender.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/TemplateRender.java
new file mode 100644
index 000000000..4cc1a62fa
--- /dev/null
+++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/TemplateRender.java
@@ -0,0 +1,16 @@
+package io.avaje.htmx.nima;
+
+
+import io.helidon.webserver.http.ServerRequest;
+import io.helidon.webserver.http.ServerResponse;
+
+/**
+ * Template render API for Helidon.
+ */
+public interface TemplateRender {
+
+ /**
+ * Render the given template view model to the server response.
+ */
+ void render(Object viewModel, ServerRequest req, ServerResponse res);
+}
diff --git a/htmx-nima/src/main/java/module-info.java b/htmx-nima/src/main/java/module-info.java
new file mode 100644
index 000000000..8adfb5b45
--- /dev/null
+++ b/htmx-nima/src/main/java/module-info.java
@@ -0,0 +1,7 @@
+module io.avaje.htmx.nima {
+
+ requires io.avaje.htmx.api;
+ requires io.helidon.webserver;
+
+ exports io.avaje.htmx.nima;
+}
diff --git a/http-generator-core/pom.xml b/http-generator-core/pom.xml
index 817ca4211..2f386802c 100644
--- a/http-generator-core/pom.xml
+++ b/http-generator-core/pom.xml
@@ -26,6 +26,14 @@
provided
+
+ io.avaje
+ avaje-htmx-api
+ ${project.version}
+ true
+ provided
+
+
io.swagger.core.v3
swagger-annotations
diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/ControllerReader.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/ControllerReader.java
index 3914821cc..12e89fdd6 100644
--- a/http-generator-core/src/main/java/io/avaje/http/generator/core/ControllerReader.java
+++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/ControllerReader.java
@@ -47,6 +47,8 @@ public final class ControllerReader {
private final String producesPrism;
private final boolean hasValid;
+ /** Set true via {@code @Html} to indicate use of Templating */
+ private final boolean html;
private boolean methodHasValid;
/**
@@ -70,7 +72,8 @@ public ControllerReader(TypeElement beanType, String contextPath) {
docHidden = initDocHidden();
}
this.hasValid = initHasValid();
- this.producesPrism = initProduces();
+ this.html = initHtml();
+ this.producesPrism = initProduces(html);
this.apiResponses = buildApiResponses();
hasInstrument =
instrumentAllWebMethods()
@@ -172,8 +175,13 @@ private boolean matchMethod(ExecutableElement interfaceMethod, ExecutableElement
return interfaceMethod.toString().equals(element.toString());
}
- private String initProduces() {
- return findAnnotation(ProducesPrism::getOptionalOn).map(ProducesPrism::value).orElse(null);
+ private boolean initHtml() {
+ return findAnnotation(HtmlPrism::getOptionalOn).isPresent();
+ }
+
+ private String initProduces(boolean html) {
+ String defaultProduces = html ? "text/html;charset=UTF8" : null;
+ return findAnnotation(ProducesPrism::getOptionalOn).map(ProducesPrism::value).orElse(defaultProduces);
}
private boolean initDocHidden() {
@@ -188,6 +196,10 @@ String produces() {
return producesPrism;
}
+ public boolean html() {
+ return html;
+ }
+
public TypeElement beanType() {
return beanType;
}
diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/MethodReader.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/MethodReader.java
index 2ffa86e20..e679f267e 100644
--- a/http-generator-core/src/main/java/io/avaje/http/generator/core/MethodReader.java
+++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/MethodReader.java
@@ -43,6 +43,7 @@ public class MethodReader {
private final boolean hasValid;
private final List superMethods;
private final Optional timeout;
+ private final HxRequestPrism hxRequest;
private WebMethod webMethod;
private int statusCode;
@@ -77,6 +78,7 @@ public class MethodReader {
this.securityRequirements = readSecurityRequirements();
this.apiResponses = buildApiResponses();
this.javadoc = buildJavadoc(element);
+ this.hxRequest = HxRequestPrism.getInstanceOn(element);
this.timeout = RequestTimeoutPrism.getOptionalOn(element);
timeout.ifPresent(
p -> {
@@ -190,6 +192,13 @@ private void initSetWebMethod(WebMethod webMethod, ExceptionHandlerPrism excepti
bean.addImportType(exType);
}
+ /**
+ * Return the Htmx request annotation for this method.
+ */
+ public HxRequestPrism hxRequest() {
+ return hxRequest;
+ }
+
public Javadoc javadoc() {
return javadoc;
}
diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/package-info.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/package-info.java
index 129ae8cc7..efdbc2609 100644
--- a/http-generator-core/src/main/java/io/avaje/http/generator/core/package-info.java
+++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/package-info.java
@@ -34,6 +34,8 @@
@GeneratePrism(value = io.swagger.v3.oas.annotations.Hidden.class, publicAccess = true)
@GeneratePrism(value = io.avaje.http.api.Client.Import.class, publicAccess = true)
@GeneratePrism(value = io.avaje.http.api.RequestTimeout.class, publicAccess = true)
+@GeneratePrism(value = io.avaje.htmx.api.HxRequest.class, publicAccess = true)
+@GeneratePrism(value = io.avaje.htmx.api.Html.class, publicAccess = true)
package io.avaje.http.generator.core;
import io.avaje.prism.GeneratePrism;
diff --git a/http-generator-core/src/main/java/module-info.java b/http-generator-core/src/main/java/module-info.java
index 0d5787e3f..f5142424b 100644
--- a/http-generator-core/src/main/java/module-info.java
+++ b/http-generator-core/src/main/java/module-info.java
@@ -10,6 +10,7 @@
// SHADED: All content after this line will be removed at package time
requires static io.avaje.prism;
requires static io.avaje.http.api;
+ requires static io.avaje.htmx.api;
requires static io.swagger.v3.oas.models;
requires static io.swagger.v3.oas.annotations;
requires static java.validation;
diff --git a/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerMethodWriter.java b/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerMethodWriter.java
index 8a31e3a51..ee7bf699b 100644
--- a/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerMethodWriter.java
+++ b/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerMethodWriter.java
@@ -7,14 +7,7 @@
import java.util.Map;
import java.util.Optional;
-import io.avaje.http.generator.core.Append;
-import io.avaje.http.generator.core.CoreWebMethod;
-import io.avaje.http.generator.core.MethodParam;
-import io.avaje.http.generator.core.MethodReader;
-import io.avaje.http.generator.core.ParamType;
-import io.avaje.http.generator.core.PathSegments;
-import io.avaje.http.generator.core.UType;
-import io.avaje.http.generator.core.WebMethod;
+import io.avaje.http.generator.core.*;
import io.avaje.http.generator.core.openapi.MediaType;
import javax.lang.model.type.TypeMirror;
@@ -67,8 +60,10 @@ final class ControllerMethodWriter {
private final boolean useJsonB;
private final boolean instrumentContext;
private final boolean isFilter;
+ private final ControllerReader reader;
- ControllerMethodWriter(MethodReader method, Append writer, boolean useJsonB) {
+ ControllerMethodWriter(MethodReader method, Append writer, boolean useJsonB, ControllerReader reader) {
+ this.reader = reader;
this.method = method;
this.writer = writer;
this.webMethod = method.webMethod();
@@ -92,10 +87,35 @@ void writeRule() {
} else if (isFilter) {
writer.append(" routing.addFilter(this::_%s);", method.simpleName()).eol();
} else {
- writer.append(" routing.%s(\"%s\", this::_%s);", webMethod.name().toLowerCase(), method.fullPath().replace("\\", "\\\\"), method.simpleName()).eol();
+ writer.append(" routing.%s(\"%s\", ", webMethod.name().toLowerCase(), method.fullPath().replace("\\", "\\\\"));
+ var hxRequest = method.hxRequest();
+ if (hxRequest != null) {
+ writer.append("HxHandler.builder(this::_%s)", method.simpleName());
+ if (hasValue(hxRequest.target())) {
+ writer.append(".target(\"%s\")", hxRequest.target());
+ }
+ if (hasValue(hxRequest.triggerId())) {
+ writer.append(".trigger(\"%s\")", hxRequest.triggerId());
+ } else if (hasValue(hxRequest.value())) {
+ writer.append(".trigger(\"%s\")", hxRequest.value());
+ }
+ if (hasValue(hxRequest.triggerName())) {
+ writer.append(".triggerName(\"%s\")", hxRequest.triggerName());
+ } else if (hasValue(hxRequest.value())) {
+ writer.append(".triggerName(\"%s\")", hxRequest.value());
+ }
+ writer.append(".build());").eol();
+
+ } else {
+ writer.append("this::_%s);", method.simpleName()).eol();
+ }
}
}
+ private static boolean hasValue(String value) {
+ return value != null && !value.isBlank();
+ }
+
void writeHandler(boolean requestScoped) {
if (method.isErrorMethod()) {
writer.append(" private void _%s(ServerRequest req, ServerResponse res, %s ex) {", method.simpleName(), method.exceptionShortName()).eol();
@@ -205,6 +225,8 @@ void writeHandler(boolean requestScoped) {
final var uType = UType.parse(method.returnType());
writer.append(indent).append("%sJsonType.toJson(result, JsonOutput.of(res));", uType.shortName()).eol();
}
+ } else if (useTemplating()) {
+ writer.append(indent).append("renderer.render(result, req, res);").eol();
} else {
writer.append(indent).append("res.send(result);").eol();
}
@@ -215,6 +237,12 @@ void writeHandler(boolean requestScoped) {
writer.append(" }").eol().eol();
}
+ private boolean useTemplating() {
+ return reader.html()
+ && !"byte[]".equals(method.returnType().toString())
+ && (method.produces() == null || method.produces().toLowerCase().contains("html"));
+ }
+
private static boolean isExceptionOrFilterChain(MethodParam param) {
return isAssignable2Interface(param.utype().mainType(), "java.lang.Exception")
|| "FilterChain".equals(param.shortType());
diff --git a/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerWriter.java b/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerWriter.java
index e192f9d05..e3254ba39 100644
--- a/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerWriter.java
+++ b/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerWriter.java
@@ -6,6 +6,7 @@
import java.io.IOException;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import io.avaje.http.generator.core.BaseControllerWriter;
import io.avaje.http.generator.core.Constants;
@@ -59,6 +60,14 @@ class ControllerWriter extends BaseControllerWriter {
reader.addImportType("io.helidon.webserver.http.RoutingRequest");
reader.addImportType("io.helidon.webserver.http.RoutingResponse");
}
+ if (reader.methods().stream()
+ .map(MethodReader::hxRequest)
+ .anyMatch(Objects::nonNull)) {
+ reader.addImportType("io.avaje.htmx.nima.HxHandler");
+ }
+ if (reader.html()) {
+ reader.addImportType("io.avaje.htmx.nima.TemplateRender");
+ }
}
void write() {
@@ -80,7 +89,7 @@ protected void writeImports() {
private List writerMethods() {
return reader.methods().stream()
.filter(MethodReader::isWebMethod)
- .map(it -> new ControllerMethodWriter(it, writer, useJsonB))
+ .map(it -> new ControllerMethodWriter(it, writer, useJsonB, reader))
.toList();
}
@@ -126,10 +135,12 @@ private void writeClassStart() {
if (reader.isIncludeValidator()) {
writer.append(" private final Validator validator;").eol();
}
-
if (instrumentContext) {
writer.append(" private final RequestContextResolver resolver;").eol();
}
+ if (reader.html()) {
+ writer.append(" private final TemplateRender renderer;").eol();
+ }
for (final UType type : jsonTypes.values()) {
if (!isInputStream(type.full())) {
@@ -146,6 +157,9 @@ private void writeClassStart() {
if (useJsonB) {
writer.append(", Jsonb jsonb");
}
+ if (reader.html()) {
+ writer.append(", TemplateRender renderer");
+ }
if (instrumentContext) {
writer.append(", RequestContextResolver resolver");
}
@@ -155,6 +169,9 @@ private void writeClassStart() {
if (reader.isIncludeValidator()) {
writer.append(" this.validator = validator;").eol();
}
+ if (reader.html()) {
+ writer.append(" this.renderer = renderer;").eol();
+ }
if (instrumentContext) {
writer.append(" this.resolver = resolver;").eol();
}
@@ -176,6 +193,6 @@ private void writeClassStart() {
}
private boolean isInputStream(String type) {
- return isAssignable2Interface(type.toString(), "java.io.InputStream");
+ return isAssignable2Interface(type, "java.io.InputStream");
}
}
diff --git a/pom.xml b/pom.xml
index 34d114bc1..4bfa390f0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -35,6 +35,7 @@
+ htmx-api
http-api
http-api-javalin
http-client
@@ -63,9 +64,20 @@
[21,22]
+ htmx-nima
+ htmx-nima-jstache
http-generator-helidon
+
+ test21
+
+ htmx-nima
+ htmx-nima-jstache
+ http-generator-helidon
+ tests
+
+
module-info.shade
diff --git a/tests/pom.xml b/tests/pom.xml
index c4173ff3a..449c3b843 100644
--- a/tests/pom.xml
+++ b/tests/pom.xml
@@ -38,6 +38,7 @@
test-nima
test-nima-jsonb
+ test-nima-htmx
diff --git a/tests/test-nima-htmx/pom.xml b/tests/test-nima-htmx/pom.xml
new file mode 100644
index 000000000..2316c1bb5
--- /dev/null
+++ b/tests/test-nima-htmx/pom.xml
@@ -0,0 +1,100 @@
+
+
+ 4.0.0
+
+ io.avaje
+ tests
+ 2.7
+
+
+ test-nima-htmx
+
+
+ 21
+ UTF-8
+ false
+ 1.3.5
+
+
+
+
+ io.avaje
+ avaje-inject
+ ${avaje-inject.version}
+
+
+ io.avaje
+ avaje-htmx-nima-jstache
+ ${project.version}
+
+
+ io.jstach
+ jstachio
+ ${io.jstach.version}
+
+
+ io.avaje
+ avaje-htmx-api
+ ${project.version}
+
+
+ io.avaje
+ avaje-htmx-nima
+ ${project.version}
+
+
+ io.avaje
+ avaje-nima
+ 1.0
+
+
+
+
+ io.avaje
+ avaje-nima-test
+ 1.0
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ 21
+
+
+ io.avaje
+ avaje-http-helidon-generator
+ ${project.version}
+
+
+ io.avaje
+ avaje-inject-generator
+ ${avaje-inject.version}
+
+
+ io.avaje
+ avaje-jsonb-generator
+ 1.11
+
+
+ io.jstach
+ jstachio-apt
+ ${io.jstach.version}
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/Main.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/Main.java
new file mode 100644
index 000000000..1b6d205ad
--- /dev/null
+++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/Main.java
@@ -0,0 +1,15 @@
+package org.example.htmx;
+
+import io.avaje.inject.InjectModule;
+import io.avaje.nima.Nima;
+
+@InjectModule(name = "hxTest")
+public class Main {
+
+ public static void main(String[] args) {
+ Nima.builder()
+ .port(8090)
+ .build()
+ .start();
+ }
+}
diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/UIController.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/UIController.java
new file mode 100644
index 000000000..05c59ded0
--- /dev/null
+++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/UIController.java
@@ -0,0 +1,29 @@
+package org.example.htmx;
+
+import io.avaje.htmx.api.Html;
+import io.avaje.htmx.api.HxRequest;
+import io.avaje.http.api.Controller;
+import io.avaje.http.api.Get;
+import io.avaje.http.api.Path;
+
+import java.time.Instant;
+import java.util.List;
+
+@Html
+@Controller
+@Path("/")
+public class UIController {
+
+ @Get
+ ViewHome index() {
+ return new ViewHome("Robin3");
+ }
+
+ @HxRequest(target = "name")
+ @Get("name")
+ ViewName name() {
+ var mlist = List.of("one","two","three", "four");
+ return new ViewName("JimBolin", Instant.now(), "MoreMeMore", mlist);
+ }
+
+}
diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewHome.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewHome.java
new file mode 100644
index 000000000..607dc690b
--- /dev/null
+++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewHome.java
@@ -0,0 +1,7 @@
+package org.example.htmx;
+
+import io.jstach.jstache.JStache;
+
+@JStache(path = "home")
+public record ViewHome(String name) {
+}
diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewName.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewName.java
new file mode 100644
index 000000000..2cda3b0cf
--- /dev/null
+++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewName.java
@@ -0,0 +1,13 @@
+package org.example.htmx;
+
+import io.jstach.jstache.JStache;
+
+import java.time.Instant;
+import java.util.List;
+
+@JStache(path = "name")
+public record ViewName(String name, Instant foo, String more, List mlist) {
+ public String when() {
+ return foo.toString();
+ }
+}
diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/package-info.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/package-info.java
new file mode 100644
index 000000000..a0e8d10d8
--- /dev/null
+++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/package-info.java
@@ -0,0 +1,4 @@
+@JStachePath(prefix = "ui/", suffix = ".mustache")
+package org.example.htmx;
+
+import io.jstach.jstache.JStachePath;
diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/template/JstacheTemplateRender.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/template/JstacheTemplateRender.java
new file mode 100644
index 000000000..4acb8ed00
--- /dev/null
+++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/template/JstacheTemplateRender.java
@@ -0,0 +1,17 @@
+package org.example.htmx.template;
+
+import io.avaje.htmx.nima.TemplateRender;
+import io.avaje.inject.Component;
+import io.helidon.webserver.http.ServerRequest;
+import io.helidon.webserver.http.ServerResponse;
+import io.jstach.jstachio.JStachio;
+
+@Component
+public class JstacheTemplateRender implements TemplateRender {
+
+ @Override
+ public void render(Object viewModel, ServerRequest req, ServerResponse res) {
+ String content = JStachio.render(viewModel);
+ res.send(content);
+ }
+}
diff --git a/tests/test-nima-htmx/src/main/resources/ui/fragments/layout.mustache b/tests/test-nima-htmx/src/main/resources/ui/fragments/layout.mustache
new file mode 100644
index 000000000..26a8c9e85
--- /dev/null
+++ b/tests/test-nima-htmx/src/main/resources/ui/fragments/layout.mustache
@@ -0,0 +1,11 @@
+
+
+
+ Hi
+
+
+
+Heading
+{{$body}}Empty body{{/body}}
+
+
diff --git a/tests/test-nima-htmx/src/main/resources/ui/home.mustache b/tests/test-nima-htmx/src/main/resources/ui/home.mustache
new file mode 100644
index 000000000..af4686584
--- /dev/null
+++ b/tests/test-nima-htmx/src/main/resources/ui/home.mustache
@@ -0,0 +1,8 @@
+{{
+ Hi there {{ name }}
+
+ One
+{{/body}}
+{{/fragments/layout}}
diff --git a/tests/test-nima-htmx/src/main/resources/ui/name.mustache b/tests/test-nima-htmx/src/main/resources/ui/name.mustache
new file mode 100644
index 000000000..4c2f0f851
--- /dev/null
+++ b/tests/test-nima-htmx/src/main/resources/ui/name.mustache
@@ -0,0 +1,6 @@
+
+Yond {{ name }} its {{ when }} !! and {{ more }} sad
+ {{#mlist}}
+ in {{.}} ot
+ {{/mlist}}
+
diff --git a/tests/test-nima-jsonb/src/main/java/org/example/path/nest/PathNestController.java b/tests/test-nima-jsonb/src/main/java/org/example/htmx/PathNestController.java
similarity index 92%
rename from tests/test-nima-jsonb/src/main/java/org/example/path/nest/PathNestController.java
rename to tests/test-nima-jsonb/src/main/java/org/example/htmx/PathNestController.java
index 47fb7479c..dcc7aa3ec 100644
--- a/tests/test-nima-jsonb/src/main/java/org/example/path/nest/PathNestController.java
+++ b/tests/test-nima-jsonb/src/main/java/org/example/htmx/PathNestController.java
@@ -1,4 +1,4 @@
-package org.example.path.nest;
+package org.example.htmx;
import io.avaje.http.api.Controller;
import io.avaje.http.api.Get;
diff --git a/tests/test-nima-jsonb/src/main/java/org/example/path/nest/package-info.java b/tests/test-nima-jsonb/src/main/java/org/example/htmx/package-info.java
similarity index 61%
rename from tests/test-nima-jsonb/src/main/java/org/example/path/nest/package-info.java
rename to tests/test-nima-jsonb/src/main/java/org/example/htmx/package-info.java
index 73f306ff6..d9695fcaa 100644
--- a/tests/test-nima-jsonb/src/main/java/org/example/path/nest/package-info.java
+++ b/tests/test-nima-jsonb/src/main/java/org/example/htmx/package-info.java
@@ -1,5 +1,5 @@
@Path("nested")
-package org.example.path.nest;
+package org.example.htmx;
import io.avaje.http.api.Path;
diff --git a/tests/test-nima-jsonb/src/main/java/org/example/path/PathTestController.java b/tests/test-nima-jsonb/src/main/java/org/example/path/PathTestController.java
index 5d1a3f970..2421bd26e 100644
--- a/tests/test-nima-jsonb/src/main/java/org/example/path/PathTestController.java
+++ b/tests/test-nima-jsonb/src/main/java/org/example/path/PathTestController.java
@@ -1,6 +1,6 @@
package org.example.path;
-import org.example.path.nest.PathNestController.NestedTypeResponse;
+import org.example.htmx.PathNestController.NestedTypeResponse;
import io.avaje.http.api.Controller;
import io.avaje.http.api.Get;