@@ -93,11 +93,12 @@ public List<String> generateHandlerCall(boolean kt) {
9393 .findFirst ()
9494 .orElse (null )
9595 : null ;
96-
97- // Strip quotes from APT extraction so string() works correctly below
98- if (errorView != null ) {
99- errorView = errorView .replace ("\" " , "" );
100- }
96+ String layoutView =
97+ hxView != null
98+ ? AnnotationSupport .findAnnotationValue (hxView , "layout" ::equals ).stream ()
99+ .findFirst ()
100+ .orElse (null )
101+ : null ;
101102
102103 boolean isDynamicResponse =
103104 getReturnType ().getRawType ().toString ().equals ("io.jooby.htmx.HtmxResponse" );
@@ -108,15 +109,29 @@ public List<String> generateHandlerCall(boolean kt) {
108109 buffer .add (statement (indent (indent ), "try {" ));
109110 indent += 2 ;
110111 }
111-
112- buffer .add (statement (indent (indent ), var (kt ), "result_ = " , call , semicolon (kt )));
113-
114- appendDeclarativeHeaders (buffer , kt , indent );
115-
116112 // 5. Response Processing
117113 if (isDynamicResponse ) {
114+ // Guard for dynamic responses (e.g. POST/DELETE endpoints)
115+ buffer .add (
116+ statement (indent (indent ), "if (!ctx.header(\" HX-Request\" ).booleanValue(false)) {" ));
117+ if (kt ) {
118+ buffer .add (
119+ statement (
120+ indent (indent + 2 ),
121+ "throw io.jooby.exception.BadRequestException(\" Direct browser access to this HTMX"
122+ + " fragment is not allowed.\" )" ));
123+ } else {
124+ buffer .add (
125+ statement (
126+ indent (indent + 2 ),
127+ "throw new io.jooby.exception.BadRequestException(\" Direct browser access to this"
128+ + " HTMX fragment is not allowed.\" );" ));
129+ }
130+ buffer .add (statement (indent (indent ), "}" ));
131+
132+ buffer .add (statement (indent (indent ), var (kt ), "result_ = " , call , semicolon (kt )));
133+
118134 if (errorView != null ) {
119- // USE IDIOMATIC KOTLIN MAPS
120135 String emptyMap = kt ? "mapOf<String, Any>()" : "java.util.Map.of()" ;
121136 buffer .add (
122137 statement (
@@ -128,10 +143,13 @@ public List<String> generateHandlerCall(boolean kt) {
128143 ")" ,
129144 semicolon (kt )));
130145 }
146+
147+ appendDeclarativeHeaders (buffer , kt , indent );
148+
131149 buffer .add (statement (indent (indent ), "return result_.send(ctx)" , semicolon (kt )));
132150 } else {
133151 generateModelAndViewReturn (
134- buffer , kt , indent , string (primaryView ).toString (), "result_" , errorView );
152+ buffer , kt , indent , string (primaryView ).toString (), call , errorView , layoutView );
135153 }
136154
137155 // 6. Error Handling block
@@ -156,8 +174,14 @@ private AnnotationMirror findHxError() {
156174 private void generateErrorCatchBlock (
157175 List <String > buffer , boolean kt , int indent , String errorView , String errorTarget ) {
158176 if (kt ) {
177+ buffer .add (
178+ statement (indent (indent ), "} catch (ex: io.jooby.htmx.HtmxDirectAccessException) {" ));
179+ buffer .add (statement (indent (indent + 2 ), "throw ex" ));
159180 buffer .add (statement (indent (indent ), "} catch (ex: Exception) {" ));
160181 } else {
182+ buffer .add (
183+ statement (indent (indent ), "} catch (io.jooby.htmx.HtmxDirectAccessException ex) {" ));
184+ buffer .add (statement (indent (indent + 2 ), "throw ex;" ));
161185 buffer .add (statement (indent (indent ), "} catch (Exception ex) {" ));
162186 }
163187
@@ -222,7 +246,9 @@ private void generateErrorCatchBlock(
222246 indent (indent + 2 ),
223247 "return io.jooby.ModelAndView.of" ,
224248 inferType ,
225- "(\" " + errorView + "\" , errorModel_)" ,
249+ "(" ,
250+ string (errorView ),
251+ ", errorModel_)" ,
226252 semicolon (kt )));
227253
228254 buffer .add (statement (indent (indent ), "}" ));
@@ -306,14 +332,104 @@ private void generateModelAndViewReturn(
306332 boolean kt ,
307333 int indent ,
308334 String viewStr ,
309- String modelStr ,
310- String errorView ) {
311- boolean isView =
335+ String call ,
336+ String errorView ,
337+ String layoutView ) {
338+ boolean isStandardView =
312339 getReturnType ().is ("io.jooby.ModelAndView" )
313- || getReturnType ().is ("io.jooby.MapModelAndView" )
314- || getReturnType ().is ("io.jooby.htmx.HtmxModelAndView" );
340+ || getReturnType ().is ("io.jooby.MapModelAndView" );
341+ boolean isHtmxView = getReturnType ().is ("io.jooby.htmx.HtmxModelAndView" );
342+ boolean isView = isStandardView || isHtmxView ;
343+
344+ // Check if the developer explicitly added @HxView
345+ boolean hasHxView =
346+ io .jooby .internal .apt .AnnotationSupport .findAnnotationByName (
347+ method , "io.jooby.annotation.htmx.HxView" )
348+ != null ;
349+
350+ // RULE: We apply the HTMX Guard Clause to EVERYTHING EXCEPT standard views lacking the @HxView
351+ // annotation.
352+ boolean requiresGuard = !isStandardView || hasHxView ;
353+
354+ var modelStr = "result_" ;
355+
356+ // ==========================================
357+ // 1. THE BROWSER FULL-REFRESH GUARD
358+ // ==========================================
359+ if (requiresGuard ) {
360+ buffer .add (
361+ statement (indent (indent ), "if (!ctx.header(\" HX-Request\" ).booleanValue(false)) {" ));
362+ if (layoutView != null && !layoutView .isEmpty ()) {
363+ buffer .add (statement (indent (indent + 2 ), var (kt ), "result_ = " , call , semicolon (kt )));
364+
365+ // Inject the child view name as a request attribute (Safe for ANY model type: Map, Record,
366+ // POJO)
367+ buffer .add (
368+ statement (
369+ indent (indent + 2 ),
370+ "ctx.setAttribute(\" childView\" , " ,
371+ viewStr ,
372+ ")" ,
373+ semicolon (kt )));
374+
375+ // Extract the data model. If the controller returned a ModelAndView, unwrap it using
376+ // .getModel()
377+ String targetModel = isView ? modelStr + ".getModel()" : modelStr ;
378+
379+ // Return a BRAND NEW immutable ModelAndView pointing to the layout
380+ if (kt ) {
381+ buffer .add (
382+ statement (
383+ indent (indent + 2 ),
384+ "return io.jooby.ModelAndView.of<Any>(" ,
385+ string (layoutView ),
386+ ", " ,
387+ targetModel ,
388+ ")" ,
389+ semicolon (kt )));
390+ } else {
391+ buffer .add (
392+ statement (
393+ indent (indent + 2 ),
394+ "return io.jooby.ModelAndView.of(" ,
395+ string (layoutView ),
396+ ", " ,
397+ targetModel ,
398+ ")" ,
399+ semicolon (kt )));
400+ }
401+
402+ } else {
403+ // No layout defined: Reject direct access
404+ if (kt ) {
405+ buffer .add (
406+ statement (
407+ indent (indent + 2 ),
408+ "throw io.jooby.htmx.HtmxDirectAccessException(\" Direct browser access to this"
409+ + " HTMX fragment is not allowed.\" )" ));
410+ } else {
411+ buffer .add (
412+ statement (
413+ indent (indent + 2 ),
414+ "throw new io.jooby.htmx.HtmxDirectAccessException(\" Direct browser access to"
415+ + " this HTMX fragment is not allowed.\" );" ));
416+ }
417+ }
418+ buffer .add (statement (indent (indent ), "}" ));
419+ }
420+
421+ // Execute the controller method if it wasn't already handled and returned by the layout block
422+ // above
423+ buffer .add (statement (indent (indent ), var (kt ), "result_ = " , call , semicolon (kt )));
424+
425+ appendDeclarativeHeaders (buffer , kt , indent );
426+
427+ // ==========================================
428+ // 2. THE HTMX AJAX PIPELINE
429+ // ==========================================
315430
316431 if (isView ) {
432+ // Controller handled its own view creation
317433 buffer .add (statement (indent (indent ), "return " , modelStr , semicolon (kt )));
318434 return ;
319435 }
@@ -323,25 +439,37 @@ private void generateModelAndViewReturn(
323439 "io.jooby.annotation.htmx.HxOob" , "io.jooby.annotation.htmx.HxOobs" );
324440
325441 if (!oobViews .isEmpty () || errorView != null ) {
326- buffer .add (
327- statement (
328- indent (indent ),
329- var (kt ),
330- "mv_ = " ,
331- kt ? "" : "new " ,
332- "io.jooby.htmx.HtmxModelAndView(" ,
333- viewStr ,
334- ", " ,
335- modelStr ,
336- ")" ,
337- semicolon (kt )));
442+ // Upgrade to HtmxModelAndView to support OOB responses
443+ if (kt ) {
444+ buffer .add (
445+ statement (
446+ indent (indent ),
447+ var (kt ),
448+ "mv_ = io.jooby.htmx.HtmxModelAndView<Any>(" ,
449+ viewStr ,
450+ ", " ,
451+ modelStr ,
452+ ")" ,
453+ semicolon (kt )));
454+ } else {
455+ buffer .add (
456+ statement (
457+ indent (indent ),
458+ var (kt ),
459+ "mv_ = new io.jooby.htmx.HtmxModelAndView<>(" ,
460+ viewStr ,
461+ ", " ,
462+ modelStr ,
463+ ")" ,
464+ semicolon (kt )));
465+ }
338466
339467 for (var oobView : oobViews ) {
340468 buffer .add (statement (indent (indent ), "mv_.addOob(" , string (oobView ), ")" , semicolon (kt )));
341469 }
342470
343- // MAGIC REPAIRED: Add the empty map parameter correctly!
344471 if (errorView != null ) {
472+ buffer .add (statement (indent (indent ), "// clear error: " , errorView ));
345473 String emptyMap = kt ? "mapOf<String, Any>()" : "java.util.Map.of()" ;
346474 buffer .add (
347475 statement (
@@ -358,18 +486,28 @@ private void generateModelAndViewReturn(
358486 return ;
359487 }
360488
361- var inferType = kt ? "<Any>" : "" ;
362- buffer .add (
363- statement (
364- indent (indent ),
365- "return io.jooby.ModelAndView.of" ,
366- inferType ,
367- "(" ,
368- viewStr ,
369- ", " ,
370- modelStr ,
371- ")" ,
372- semicolon (kt )));
489+ // Fallback: Standard Jooby ModelAndView
490+ if (kt ) {
491+ buffer .add (
492+ statement (
493+ indent (indent ),
494+ "return io.jooby.ModelAndView.of<Any>(" ,
495+ viewStr ,
496+ ", " ,
497+ modelStr ,
498+ ")" ,
499+ semicolon (kt )));
500+ } else {
501+ buffer .add (
502+ statement (
503+ indent (indent ),
504+ "return io.jooby.ModelAndView.of(" ,
505+ viewStr ,
506+ ", " ,
507+ modelStr ,
508+ ")" ,
509+ semicolon (kt )));
510+ }
373511 }
374512
375513 private void appendDeclarativeHeaders (List <String > buffer , boolean kt , int indent ) {
0 commit comments