├── .editorconfig ├── .gitattributes ├── .gitignore ├── README.md ├── bin ├── _init.sh ├── release └── update-lib-versions ├── build-logic ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ ├── common-conventions.gradle.kts │ └── publish-conventions.gradle.kts ├── build.gradle.kts ├── example ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── iodesystems │ └── htmx │ └── example │ ├── Example.kt │ ├── config │ ├── WebConfig.kt │ ├── WebSocketConfig.kt │ └── sockets │ │ └── MappedWebSocketHandler.kt │ └── controllers │ └── ChatController.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── htmx ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com.iodesystems.htmx │ ├── Htmx.kt │ ├── HtmxAttrs.kt │ └── HtmxTrigger.kt ├── renovate.json ├── settings.gradle.kts ├── spring ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── iodesystems │ └── htmx │ └── example │ └── HtmxMessageConverter.kt └── versions.properties /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 120 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | ij_continuation_indent_size = 8 13 | ij_formatter_off_tag = @formatter:off 14 | ij_formatter_on_tag = @formatter:on 15 | ij_formatter_tags_enabled = true 16 | ij_smart_tabs = false 17 | ij_visual_guides = 80 18 | ij_wrap_on_typing = false 19 | 20 | [*.css] 21 | ij_visual_guides = none 22 | ij_css_align_closing_brace_with_properties = false 23 | ij_css_blank_lines_around_nested_selector = 1 24 | ij_css_blank_lines_between_blocks = 1 25 | ij_css_block_comment_add_space = false 26 | ij_css_brace_placement = end_of_line 27 | ij_css_enforce_quotes_on_format = false 28 | ij_css_hex_color_long_format = false 29 | ij_css_hex_color_lower_case = false 30 | ij_css_hex_color_short_format = false 31 | ij_css_hex_color_upper_case = false 32 | ij_css_keep_blank_lines_in_code = 2 33 | ij_css_keep_indents_on_empty_lines = false 34 | ij_css_keep_single_line_blocks = false 35 | ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow 36 | ij_css_space_after_colon = true 37 | ij_css_space_before_opening_brace = true 38 | ij_css_use_double_quotes = true 39 | ij_css_value_alignment = do_not_align 40 | 41 | [*.csv] 42 | indent_style = tab 43 | ij_visual_guides = none 44 | ij_csv_keep_indents_on_empty_lines = true 45 | ij_csv_wrap_long_lines = false 46 | 47 | [.editorconfig] 48 | ij_visual_guides = none 49 | ij_editorconfig_align_group_field_declarations = false 50 | ij_editorconfig_space_after_colon = false 51 | ij_editorconfig_space_after_comma = true 52 | ij_editorconfig_space_before_colon = false 53 | ij_editorconfig_space_before_comma = false 54 | ij_editorconfig_spaces_around_assignment_operators = true 55 | 56 | [{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.pom,*.rng,*.tld,*.wadl,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul,phpunit.xml.dist}] 57 | indent_size = 2 58 | tab_width = 2 59 | ij_continuation_indent_size = 4 60 | ij_visual_guides = none 61 | ij_xml_align_attributes = true 62 | ij_xml_align_text = false 63 | ij_xml_attribute_wrap = normal 64 | ij_xml_block_comment_add_space = false 65 | ij_xml_block_comment_at_first_column = true 66 | ij_xml_keep_blank_lines = 2 67 | ij_xml_keep_indents_on_empty_lines = false 68 | ij_xml_keep_line_breaks = true 69 | ij_xml_keep_line_breaks_in_text = true 70 | ij_xml_keep_whitespaces = false 71 | ij_xml_keep_whitespaces_around_cdata = preserve 72 | ij_xml_keep_whitespaces_inside_cdata = false 73 | ij_xml_line_comment_at_first_column = true 74 | ij_xml_space_after_tag_name = false 75 | ij_xml_space_around_equals_in_attribute = false 76 | ij_xml_space_inside_empty_tag = false 77 | ij_xml_text_wrap = normal 78 | 79 | [{*.ats,*.cts,*.mts,*.ts}] 80 | indent_size = 2 81 | ij_continuation_indent_size = 2 82 | ij_visual_guides = none 83 | ij_typescript_align_imports = false 84 | ij_typescript_align_multiline_array_initializer_expression = false 85 | ij_typescript_align_multiline_binary_operation = false 86 | ij_typescript_align_multiline_chained_methods = false 87 | ij_typescript_align_multiline_extends_list = false 88 | ij_typescript_align_multiline_for = false 89 | ij_typescript_align_multiline_parameters = false 90 | ij_typescript_align_multiline_parameters_in_calls = false 91 | ij_typescript_align_multiline_ternary_operation = false 92 | ij_typescript_align_object_properties = 0 93 | ij_typescript_align_union_types = false 94 | ij_typescript_align_var_statements = 0 95 | ij_typescript_array_initializer_new_line_after_left_brace = false 96 | ij_typescript_array_initializer_right_brace_on_new_line = false 97 | ij_typescript_array_initializer_wrap = off 98 | ij_typescript_assignment_wrap = off 99 | ij_typescript_binary_operation_sign_on_next_line = false 100 | ij_typescript_binary_operation_wrap = off 101 | ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** 102 | ij_typescript_blank_lines_after_imports = 1 103 | ij_typescript_blank_lines_around_class = 1 104 | ij_typescript_blank_lines_around_field = 0 105 | ij_typescript_blank_lines_around_field_in_interface = 0 106 | ij_typescript_blank_lines_around_function = 1 107 | ij_typescript_blank_lines_around_method = 1 108 | ij_typescript_blank_lines_around_method_in_interface = 1 109 | ij_typescript_block_brace_style = end_of_line 110 | ij_typescript_block_comment_add_space = false 111 | ij_typescript_block_comment_at_first_column = true 112 | ij_typescript_call_parameters_new_line_after_left_paren = false 113 | ij_typescript_call_parameters_right_paren_on_new_line = false 114 | ij_typescript_call_parameters_wrap = off 115 | ij_typescript_catch_on_new_line = false 116 | ij_typescript_chained_call_dot_on_new_line = true 117 | ij_typescript_class_brace_style = end_of_line 118 | ij_typescript_comma_on_new_line = false 119 | ij_typescript_do_while_brace_force = never 120 | ij_typescript_else_on_new_line = false 121 | ij_typescript_enforce_trailing_comma = keep 122 | ij_typescript_enum_constants_wrap = on_every_item 123 | ij_typescript_extends_keyword_wrap = off 124 | ij_typescript_extends_list_wrap = off 125 | ij_typescript_field_prefix = _ 126 | ij_typescript_file_name_style = relaxed 127 | ij_typescript_finally_on_new_line = false 128 | ij_typescript_for_brace_force = never 129 | ij_typescript_for_statement_new_line_after_left_paren = false 130 | ij_typescript_for_statement_right_paren_on_new_line = false 131 | ij_typescript_for_statement_wrap = off 132 | ij_typescript_force_quote_style = true 133 | ij_typescript_force_semicolon_style = true 134 | ij_typescript_function_expression_brace_style = end_of_line 135 | ij_typescript_if_brace_force = never 136 | ij_typescript_import_merge_members = global 137 | ij_typescript_import_prefer_absolute_path = global 138 | ij_typescript_import_sort_members = true 139 | ij_typescript_import_sort_module_name = false 140 | ij_typescript_import_use_node_resolution = true 141 | ij_typescript_imports_wrap = on_every_item 142 | ij_typescript_indent_case_from_switch = false 143 | ij_typescript_indent_chained_calls = true 144 | ij_typescript_indent_package_children = 0 145 | ij_typescript_jsdoc_include_types = false 146 | ij_typescript_jsx_attribute_value = braces 147 | ij_typescript_keep_blank_lines_in_code = 2 148 | ij_typescript_keep_first_column_comment = true 149 | ij_typescript_keep_indents_on_empty_lines = false 150 | ij_typescript_keep_line_breaks = true 151 | ij_typescript_keep_simple_blocks_in_one_line = false 152 | ij_typescript_keep_simple_methods_in_one_line = false 153 | ij_typescript_line_comment_add_space = true 154 | ij_typescript_line_comment_at_first_column = false 155 | ij_typescript_method_brace_style = end_of_line 156 | ij_typescript_method_call_chain_wrap = off 157 | ij_typescript_method_parameters_new_line_after_left_paren = false 158 | ij_typescript_method_parameters_right_paren_on_new_line = false 159 | ij_typescript_method_parameters_wrap = off 160 | ij_typescript_object_literal_wrap = on_every_item 161 | ij_typescript_object_types_wrap = on_every_item 162 | ij_typescript_parentheses_expression_new_line_after_left_paren = false 163 | ij_typescript_parentheses_expression_right_paren_on_new_line = false 164 | ij_typescript_place_assignment_sign_on_next_line = false 165 | ij_typescript_prefer_as_type_cast = false 166 | ij_typescript_prefer_explicit_types_function_expression_returns = false 167 | ij_typescript_prefer_explicit_types_function_returns = false 168 | ij_typescript_prefer_explicit_types_vars_fields = false 169 | ij_typescript_prefer_parameters_wrap = false 170 | ij_typescript_reformat_c_style_comments = false 171 | ij_typescript_space_after_colon = true 172 | ij_typescript_space_after_comma = true 173 | ij_typescript_space_after_dots_in_rest_parameter = false 174 | ij_typescript_space_after_generator_mult = true 175 | ij_typescript_space_after_property_colon = true 176 | ij_typescript_space_after_quest = true 177 | ij_typescript_space_after_type_colon = true 178 | ij_typescript_space_after_unary_not = false 179 | ij_typescript_space_before_async_arrow_lparen = true 180 | ij_typescript_space_before_catch_keyword = true 181 | ij_typescript_space_before_catch_left_brace = true 182 | ij_typescript_space_before_catch_parentheses = true 183 | ij_typescript_space_before_class_lbrace = true 184 | ij_typescript_space_before_class_left_brace = true 185 | ij_typescript_space_before_colon = true 186 | ij_typescript_space_before_comma = false 187 | ij_typescript_space_before_do_left_brace = true 188 | ij_typescript_space_before_else_keyword = true 189 | ij_typescript_space_before_else_left_brace = true 190 | ij_typescript_space_before_finally_keyword = true 191 | ij_typescript_space_before_finally_left_brace = true 192 | ij_typescript_space_before_for_left_brace = true 193 | ij_typescript_space_before_for_parentheses = true 194 | ij_typescript_space_before_for_semicolon = false 195 | ij_typescript_space_before_function_left_parenth = true 196 | ij_typescript_space_before_generator_mult = false 197 | ij_typescript_space_before_if_left_brace = true 198 | ij_typescript_space_before_if_parentheses = true 199 | ij_typescript_space_before_method_call_parentheses = false 200 | ij_typescript_space_before_method_left_brace = true 201 | ij_typescript_space_before_method_parentheses = false 202 | ij_typescript_space_before_property_colon = false 203 | ij_typescript_space_before_quest = true 204 | ij_typescript_space_before_switch_left_brace = true 205 | ij_typescript_space_before_switch_parentheses = true 206 | ij_typescript_space_before_try_left_brace = true 207 | ij_typescript_space_before_type_colon = false 208 | ij_typescript_space_before_unary_not = false 209 | ij_typescript_space_before_while_keyword = true 210 | ij_typescript_space_before_while_left_brace = true 211 | ij_typescript_space_before_while_parentheses = true 212 | ij_typescript_spaces_around_additive_operators = true 213 | ij_typescript_spaces_around_arrow_function_operator = true 214 | ij_typescript_spaces_around_assignment_operators = true 215 | ij_typescript_spaces_around_bitwise_operators = true 216 | ij_typescript_spaces_around_equality_operators = true 217 | ij_typescript_spaces_around_logical_operators = true 218 | ij_typescript_spaces_around_multiplicative_operators = true 219 | ij_typescript_spaces_around_relational_operators = true 220 | ij_typescript_spaces_around_shift_operators = true 221 | ij_typescript_spaces_around_unary_operator = false 222 | ij_typescript_spaces_within_array_initializer_brackets = false 223 | ij_typescript_spaces_within_brackets = false 224 | ij_typescript_spaces_within_catch_parentheses = false 225 | ij_typescript_spaces_within_for_parentheses = false 226 | ij_typescript_spaces_within_if_parentheses = false 227 | ij_typescript_spaces_within_imports = false 228 | ij_typescript_spaces_within_interpolation_expressions = false 229 | ij_typescript_spaces_within_method_call_parentheses = false 230 | ij_typescript_spaces_within_method_parentheses = false 231 | ij_typescript_spaces_within_object_literal_braces = false 232 | ij_typescript_spaces_within_object_type_braces = true 233 | ij_typescript_spaces_within_parentheses = false 234 | ij_typescript_spaces_within_switch_parentheses = false 235 | ij_typescript_spaces_within_type_assertion = false 236 | ij_typescript_spaces_within_union_types = true 237 | ij_typescript_spaces_within_while_parentheses = false 238 | ij_typescript_special_else_if_treatment = true 239 | ij_typescript_ternary_operation_signs_on_next_line = false 240 | ij_typescript_ternary_operation_wrap = off 241 | ij_typescript_union_types_wrap = on_every_item 242 | ij_typescript_use_chained_calls_group_indents = false 243 | ij_typescript_use_double_quotes = true 244 | ij_typescript_use_explicit_js_extension = auto 245 | ij_typescript_use_path_mapping = always 246 | ij_typescript_use_public_modifier = false 247 | ij_typescript_use_semicolon_after_statement = false 248 | ij_typescript_var_declaration_wrap = normal 249 | ij_typescript_while_brace_force = never 250 | ij_typescript_while_on_new_line = false 251 | ij_typescript_wrap_comments = false 252 | 253 | [{*.cjs,*.js}] 254 | indent_size = 2 255 | ij_continuation_indent_size = 2 256 | ij_visual_guides = none 257 | ij_javascript_align_imports = false 258 | ij_javascript_align_multiline_array_initializer_expression = false 259 | ij_javascript_align_multiline_binary_operation = false 260 | ij_javascript_align_multiline_chained_methods = false 261 | ij_javascript_align_multiline_extends_list = false 262 | ij_javascript_align_multiline_for = false 263 | ij_javascript_align_multiline_parameters = false 264 | ij_javascript_align_multiline_parameters_in_calls = false 265 | ij_javascript_align_multiline_ternary_operation = false 266 | ij_javascript_align_object_properties = 0 267 | ij_javascript_align_union_types = false 268 | ij_javascript_align_var_statements = 0 269 | ij_javascript_array_initializer_new_line_after_left_brace = false 270 | ij_javascript_array_initializer_right_brace_on_new_line = false 271 | ij_javascript_array_initializer_wrap = off 272 | ij_javascript_assignment_wrap = off 273 | ij_javascript_binary_operation_sign_on_next_line = false 274 | ij_javascript_binary_operation_wrap = off 275 | ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** 276 | ij_javascript_blank_lines_after_imports = 1 277 | ij_javascript_blank_lines_around_class = 1 278 | ij_javascript_blank_lines_around_field = 0 279 | ij_javascript_blank_lines_around_function = 1 280 | ij_javascript_blank_lines_around_method = 1 281 | ij_javascript_block_brace_style = end_of_line 282 | ij_javascript_block_comment_add_space = false 283 | ij_javascript_block_comment_at_first_column = true 284 | ij_javascript_call_parameters_new_line_after_left_paren = false 285 | ij_javascript_call_parameters_right_paren_on_new_line = false 286 | ij_javascript_call_parameters_wrap = off 287 | ij_javascript_catch_on_new_line = false 288 | ij_javascript_chained_call_dot_on_new_line = true 289 | ij_javascript_class_brace_style = end_of_line 290 | ij_javascript_comma_on_new_line = false 291 | ij_javascript_do_while_brace_force = never 292 | ij_javascript_else_on_new_line = false 293 | ij_javascript_enforce_trailing_comma = keep 294 | ij_javascript_extends_keyword_wrap = off 295 | ij_javascript_extends_list_wrap = off 296 | ij_javascript_field_prefix = _ 297 | ij_javascript_file_name_style = relaxed 298 | ij_javascript_finally_on_new_line = false 299 | ij_javascript_for_brace_force = never 300 | ij_javascript_for_statement_new_line_after_left_paren = false 301 | ij_javascript_for_statement_right_paren_on_new_line = false 302 | ij_javascript_for_statement_wrap = off 303 | ij_javascript_force_quote_style = true 304 | ij_javascript_force_semicolon_style = true 305 | ij_javascript_function_expression_brace_style = end_of_line 306 | ij_javascript_if_brace_force = never 307 | ij_javascript_import_merge_members = global 308 | ij_javascript_import_prefer_absolute_path = global 309 | ij_javascript_import_sort_members = true 310 | ij_javascript_import_sort_module_name = false 311 | ij_javascript_import_use_node_resolution = true 312 | ij_javascript_imports_wrap = on_every_item 313 | ij_javascript_indent_case_from_switch = false 314 | ij_javascript_indent_chained_calls = true 315 | ij_javascript_indent_package_children = 0 316 | ij_javascript_jsx_attribute_value = braces 317 | ij_javascript_keep_blank_lines_in_code = 2 318 | ij_javascript_keep_first_column_comment = true 319 | ij_javascript_keep_indents_on_empty_lines = false 320 | ij_javascript_keep_line_breaks = true 321 | ij_javascript_keep_simple_blocks_in_one_line = false 322 | ij_javascript_keep_simple_methods_in_one_line = false 323 | ij_javascript_line_comment_add_space = true 324 | ij_javascript_line_comment_at_first_column = false 325 | ij_javascript_method_brace_style = end_of_line 326 | ij_javascript_method_call_chain_wrap = off 327 | ij_javascript_method_parameters_new_line_after_left_paren = false 328 | ij_javascript_method_parameters_right_paren_on_new_line = false 329 | ij_javascript_method_parameters_wrap = off 330 | ij_javascript_object_literal_wrap = on_every_item 331 | ij_javascript_object_types_wrap = on_every_item 332 | ij_javascript_parentheses_expression_new_line_after_left_paren = false 333 | ij_javascript_parentheses_expression_right_paren_on_new_line = false 334 | ij_javascript_place_assignment_sign_on_next_line = false 335 | ij_javascript_prefer_as_type_cast = false 336 | ij_javascript_prefer_explicit_types_function_expression_returns = false 337 | ij_javascript_prefer_explicit_types_function_returns = false 338 | ij_javascript_prefer_explicit_types_vars_fields = false 339 | ij_javascript_prefer_parameters_wrap = false 340 | ij_javascript_reformat_c_style_comments = false 341 | ij_javascript_space_after_colon = true 342 | ij_javascript_space_after_comma = true 343 | ij_javascript_space_after_dots_in_rest_parameter = false 344 | ij_javascript_space_after_generator_mult = true 345 | ij_javascript_space_after_property_colon = true 346 | ij_javascript_space_after_quest = true 347 | ij_javascript_space_after_type_colon = true 348 | ij_javascript_space_after_unary_not = false 349 | ij_javascript_space_before_async_arrow_lparen = true 350 | ij_javascript_space_before_catch_keyword = true 351 | ij_javascript_space_before_catch_left_brace = true 352 | ij_javascript_space_before_catch_parentheses = true 353 | ij_javascript_space_before_class_lbrace = true 354 | ij_javascript_space_before_class_left_brace = true 355 | ij_javascript_space_before_colon = true 356 | ij_javascript_space_before_comma = false 357 | ij_javascript_space_before_do_left_brace = true 358 | ij_javascript_space_before_else_keyword = true 359 | ij_javascript_space_before_else_left_brace = true 360 | ij_javascript_space_before_finally_keyword = true 361 | ij_javascript_space_before_finally_left_brace = true 362 | ij_javascript_space_before_for_left_brace = true 363 | ij_javascript_space_before_for_parentheses = true 364 | ij_javascript_space_before_for_semicolon = false 365 | ij_javascript_space_before_function_left_parenth = true 366 | ij_javascript_space_before_generator_mult = false 367 | ij_javascript_space_before_if_left_brace = true 368 | ij_javascript_space_before_if_parentheses = true 369 | ij_javascript_space_before_method_call_parentheses = false 370 | ij_javascript_space_before_method_left_brace = true 371 | ij_javascript_space_before_method_parentheses = false 372 | ij_javascript_space_before_property_colon = false 373 | ij_javascript_space_before_quest = true 374 | ij_javascript_space_before_switch_left_brace = true 375 | ij_javascript_space_before_switch_parentheses = true 376 | ij_javascript_space_before_try_left_brace = true 377 | ij_javascript_space_before_type_colon = false 378 | ij_javascript_space_before_unary_not = false 379 | ij_javascript_space_before_while_keyword = true 380 | ij_javascript_space_before_while_left_brace = true 381 | ij_javascript_space_before_while_parentheses = true 382 | ij_javascript_spaces_around_additive_operators = true 383 | ij_javascript_spaces_around_arrow_function_operator = true 384 | ij_javascript_spaces_around_assignment_operators = true 385 | ij_javascript_spaces_around_bitwise_operators = true 386 | ij_javascript_spaces_around_equality_operators = true 387 | ij_javascript_spaces_around_logical_operators = true 388 | ij_javascript_spaces_around_multiplicative_operators = true 389 | ij_javascript_spaces_around_relational_operators = true 390 | ij_javascript_spaces_around_shift_operators = true 391 | ij_javascript_spaces_around_unary_operator = false 392 | ij_javascript_spaces_within_array_initializer_brackets = false 393 | ij_javascript_spaces_within_brackets = false 394 | ij_javascript_spaces_within_catch_parentheses = false 395 | ij_javascript_spaces_within_for_parentheses = false 396 | ij_javascript_spaces_within_if_parentheses = false 397 | ij_javascript_spaces_within_imports = false 398 | ij_javascript_spaces_within_interpolation_expressions = false 399 | ij_javascript_spaces_within_method_call_parentheses = false 400 | ij_javascript_spaces_within_method_parentheses = false 401 | ij_javascript_spaces_within_object_literal_braces = false 402 | ij_javascript_spaces_within_object_type_braces = true 403 | ij_javascript_spaces_within_parentheses = false 404 | ij_javascript_spaces_within_switch_parentheses = false 405 | ij_javascript_spaces_within_type_assertion = false 406 | ij_javascript_spaces_within_union_types = true 407 | ij_javascript_spaces_within_while_parentheses = false 408 | ij_javascript_special_else_if_treatment = true 409 | ij_javascript_ternary_operation_signs_on_next_line = false 410 | ij_javascript_ternary_operation_wrap = off 411 | ij_javascript_union_types_wrap = on_every_item 412 | ij_javascript_use_chained_calls_group_indents = false 413 | ij_javascript_use_double_quotes = true 414 | ij_javascript_use_explicit_js_extension = auto 415 | ij_javascript_use_path_mapping = always 416 | ij_javascript_use_public_modifier = false 417 | ij_javascript_use_semicolon_after_statement = false 418 | ij_javascript_var_declaration_wrap = normal 419 | ij_javascript_while_brace_force = never 420 | ij_javascript_while_on_new_line = false 421 | ij_javascript_wrap_comments = false 422 | 423 | [{*.htm,*.html,*.sht,*.shtm,*.shtml}] 424 | ij_visual_guides = none 425 | ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3 426 | ij_html_align_attributes = true 427 | ij_html_align_text = false 428 | ij_html_attribute_wrap = normal 429 | ij_html_block_comment_add_space = false 430 | ij_html_block_comment_at_first_column = true 431 | ij_html_do_not_align_children_of_min_lines = 0 432 | ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p 433 | ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot 434 | ij_html_enforce_quotes = false 435 | ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var 436 | ij_html_keep_blank_lines = 2 437 | ij_html_keep_indents_on_empty_lines = false 438 | ij_html_keep_line_breaks = true 439 | ij_html_keep_line_breaks_in_text = true 440 | ij_html_keep_whitespaces = false 441 | ij_html_keep_whitespaces_inside = span,pre,textarea 442 | ij_html_line_comment_at_first_column = true 443 | ij_html_new_line_after_last_attribute = never 444 | ij_html_new_line_before_first_attribute = never 445 | ij_html_quote_style = double 446 | ij_html_remove_new_line_before_tags = br 447 | ij_html_space_after_tag_name = false 448 | ij_html_space_around_equality_in_attribute = false 449 | ij_html_space_inside_empty_tag = false 450 | ij_html_text_wrap = normal 451 | 452 | [{*.kt,*.kts}] 453 | indent_size = 2 454 | tab_width = 2 455 | ij_continuation_indent_size = 4 456 | ij_visual_guides = none 457 | ij_kotlin_align_in_columns_case_branch = false 458 | ij_kotlin_align_multiline_binary_operation = false 459 | ij_kotlin_align_multiline_extends_list = false 460 | ij_kotlin_align_multiline_method_parentheses = false 461 | ij_kotlin_align_multiline_parameters = true 462 | ij_kotlin_align_multiline_parameters_in_calls = false 463 | ij_kotlin_allow_trailing_comma = false 464 | ij_kotlin_allow_trailing_comma_on_call_site = false 465 | ij_kotlin_assignment_wrap = normal 466 | ij_kotlin_blank_lines_after_class_header = 0 467 | ij_kotlin_blank_lines_around_block_when_branches = 0 468 | ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 469 | ij_kotlin_block_comment_add_space = false 470 | ij_kotlin_block_comment_at_first_column = true 471 | ij_kotlin_call_parameters_new_line_after_left_paren = true 472 | ij_kotlin_call_parameters_right_paren_on_new_line = true 473 | ij_kotlin_call_parameters_wrap = on_every_item 474 | ij_kotlin_catch_on_new_line = false 475 | ij_kotlin_class_annotation_wrap = split_into_lines 476 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 477 | ij_kotlin_continuation_indent_for_chained_calls = false 478 | ij_kotlin_continuation_indent_for_expression_bodies = false 479 | ij_kotlin_continuation_indent_in_argument_lists = false 480 | ij_kotlin_continuation_indent_in_elvis = false 481 | ij_kotlin_continuation_indent_in_if_conditions = false 482 | ij_kotlin_continuation_indent_in_parameter_lists = false 483 | ij_kotlin_continuation_indent_in_supertype_lists = false 484 | ij_kotlin_else_on_new_line = false 485 | ij_kotlin_enum_constants_wrap = off 486 | ij_kotlin_extends_list_wrap = normal 487 | ij_kotlin_field_annotation_wrap = split_into_lines 488 | ij_kotlin_finally_on_new_line = false 489 | ij_kotlin_if_rparen_on_new_line = true 490 | ij_kotlin_import_nested_classes = false 491 | ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ 492 | ij_kotlin_insert_whitespaces_in_simple_one_line_method = true 493 | ij_kotlin_keep_blank_lines_before_right_brace = 2 494 | ij_kotlin_keep_blank_lines_in_code = 2 495 | ij_kotlin_keep_blank_lines_in_declarations = 2 496 | ij_kotlin_keep_first_column_comment = true 497 | ij_kotlin_keep_indents_on_empty_lines = false 498 | ij_kotlin_keep_line_breaks = true 499 | ij_kotlin_lbrace_on_next_line = false 500 | ij_kotlin_line_break_after_multiline_when_entry = true 501 | ij_kotlin_line_comment_add_space = false 502 | ij_kotlin_line_comment_add_space_on_reformat = false 503 | ij_kotlin_line_comment_at_first_column = true 504 | ij_kotlin_method_annotation_wrap = split_into_lines 505 | ij_kotlin_method_call_chain_wrap = normal 506 | ij_kotlin_method_parameters_new_line_after_left_paren = true 507 | ij_kotlin_method_parameters_right_paren_on_new_line = true 508 | ij_kotlin_method_parameters_wrap = on_every_item 509 | ij_kotlin_name_count_to_use_star_import = 5 510 | ij_kotlin_name_count_to_use_star_import_for_members = 3 511 | ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.** 512 | ij_kotlin_parameter_annotation_wrap = off 513 | ij_kotlin_space_after_comma = true 514 | ij_kotlin_space_after_extend_colon = true 515 | ij_kotlin_space_after_type_colon = true 516 | ij_kotlin_space_before_catch_parentheses = true 517 | ij_kotlin_space_before_comma = false 518 | ij_kotlin_space_before_extend_colon = true 519 | ij_kotlin_space_before_for_parentheses = true 520 | ij_kotlin_space_before_if_parentheses = true 521 | ij_kotlin_space_before_lambda_arrow = true 522 | ij_kotlin_space_before_type_colon = false 523 | ij_kotlin_space_before_when_parentheses = true 524 | ij_kotlin_space_before_while_parentheses = true 525 | ij_kotlin_spaces_around_additive_operators = true 526 | ij_kotlin_spaces_around_assignment_operators = true 527 | ij_kotlin_spaces_around_equality_operators = true 528 | ij_kotlin_spaces_around_function_type_arrow = true 529 | ij_kotlin_spaces_around_logical_operators = true 530 | ij_kotlin_spaces_around_multiplicative_operators = true 531 | ij_kotlin_spaces_around_range = false 532 | ij_kotlin_spaces_around_relational_operators = true 533 | ij_kotlin_spaces_around_unary_operator = false 534 | ij_kotlin_spaces_around_when_arrow = true 535 | ij_kotlin_variable_annotation_wrap = off 536 | ij_kotlin_while_on_new_line = false 537 | ij_kotlin_wrap_elvis_expressions = 1 538 | ij_kotlin_wrap_expression_body_functions = 1 539 | ij_kotlin_wrap_first_method_in_call_chain = false 540 | 541 | [{*.markdown,*.md}] 542 | ij_visual_guides = none 543 | ij_markdown_force_one_space_after_blockquote_symbol = true 544 | ij_markdown_force_one_space_after_header_symbol = true 545 | ij_markdown_force_one_space_after_list_bullet = true 546 | ij_markdown_force_one_space_between_words = true 547 | ij_markdown_format_tables = true 548 | ij_markdown_insert_quote_arrows_on_wrap = true 549 | ij_markdown_keep_indents_on_empty_lines = false 550 | ij_markdown_keep_line_breaks_inside_text_blocks = true 551 | ij_markdown_max_lines_around_block_elements = 1 552 | ij_markdown_max_lines_around_header = 1 553 | ij_markdown_max_lines_between_paragraphs = 1 554 | ij_markdown_min_lines_around_block_elements = 1 555 | ij_markdown_min_lines_around_header = 1 556 | ij_markdown_min_lines_between_paragraphs = 1 557 | ij_markdown_wrap_text_if_long = true 558 | ij_markdown_wrap_text_inside_blockquotes = true 559 | 560 | [{*.properties,spring.handlers,spring.schemas}] 561 | ij_visual_guides = none 562 | ij_properties_align_group_field_declarations = false 563 | ij_properties_keep_blank_lines = false 564 | ij_properties_key_value_delimiter = equals 565 | ij_properties_spaces_around_key_value_delimiter = false 566 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # Linux start script should use lf 5 | /gradlew text eol=lf 6 | 7 | # These are Windows script files and should use crlf 8 | *.bat text eol=crlf 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore Gradle project-specific cache directory 2 | .gradle 3 | 4 | # Ignore Gradle build output directory 5 | build 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kotlinx-htmx 2 | 3 | Ever wanted to... build (?) your no-build web application in kotlin? 4 | 5 | `kotlinx-htmx` supports 100% of the apis from [htmx.org](https://htmx.org/) on top of `kotlinx.html`. 6 | 7 | ## TL;DR 8 | ```kotlin 9 | data class Counter(val count: Int) 10 | @PostMapping("/counter") 11 | fun counterButton( 12 | @RequestBody counter: Counter = Counter(0) 13 | ) = Htmx { 14 | if (counter.count > 0) { 15 | // Let the indicator show! 16 | Thread.sleep(1.seconds.toJavaDuration()) 17 | } 18 | div{ 19 | id = "counter" 20 | div{ 21 | id = "indicator" 22 | +"Loading..." 23 | } 24 | button { 25 | hx { 26 | post("/counter") 27 | swap() 28 | indicator("#indicator") 29 | trigger { 30 | click() 31 | queue(HtmxTrigger.Queue.first) 32 | vals(objectMapper.writeValueAsString(counter.copy(count = counter.count + 1))) 33 | } 34 | } 35 | +"Click Count ${counter.count}" 36 | } 37 | } 38 | } 39 | ``` 40 | See the `example` project for more details. 41 | 42 | ## Installation 43 | Maven: 44 | ```xml 45 | 46 | 47 | ... 48 | 49 | com.iodesystems.kotlinx-htmx 50 | htmx 51 | ${check-mvn-repository} 52 | 53 | 54 | 55 | com.iodesystems.kotlinx-htmx 56 | spring 57 | ${check-mvn-repository} 58 | 59 | ... 60 | 61 | ``` 62 | 63 | Gradle: 64 | ```kotlin 65 | // file: build.gradle.kts 66 | repositories { 67 | mavenCentral() 68 | } 69 | dependencies { 70 | implementation("com.iodesystems.kotlinx-htmx:htmx:${check-mvn-repository}") 71 | // If you want to use it with spring-web 72 | implementation("com.iodesystems.kotlinx-htmx:spring:${check-mvn-repository}") 73 | } 74 | ``` 75 | 76 | ## Spring Integration 77 | To be able to return `Htmx` from your controller, you need to add `HtmxHttpMessageConverter` to your `WebMvcConfigurer`: 78 | ```kotlin 79 | @Configuration 80 | open class WebConfig : WebMvcConfigurer { 81 | override fun extendMessageConverters(converters: MutableList>) { 82 | converters.add(0, HtmxHttpMessageConverter()) 83 | } 84 | } 85 | ``` 86 | 87 | # License 88 | 89 | MIT License 90 | -------------------------------------------------------------------------------- /bin/_init.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | SCRIPT_SOURCE="${BASH_SOURCE[0]}" 4 | SCRIPT_DIR_RELATIVE="$(dirname "$SCRIPT_SOURCE")" 5 | SCRIPT_DIR_ABSOLUTE="$(cd "$SCRIPT_DIR_RELATIVE" && pwd)" 6 | cd "$SCRIPT_DIR_ABSOLUTE"/../ 7 | 8 | PATH="$(pwd)"/bin:$PATH 9 | 10 | export JAVA_HOME=/Library/Java/JavaVirtualMachines/amazon-corretto-21.jdk/Contents/Home 11 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | source "$(dirname "$0")"/_init.sh 3 | 4 | ./gradlew clean publishToSonatype closeAndReleaseSonatypeStagingRepository 5 | -------------------------------------------------------------------------------- /bin/update-lib-versions: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | source "$(dirname "$0")"/_init.sh 3 | 4 | ./gradlew refreshVersions 5 | -------------------------------------------------------------------------------- /build-logic/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | mavenCentral() 7 | gradlePluginPortal() 8 | } 9 | 10 | dependencies { 11 | implementation(libs.gradle.plugin.kotlin) 12 | } 13 | -------------------------------------------------------------------------------- /build-logic/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | // Reuse version catalog from the main build. 3 | versionCatalogs { 4 | create("libs") { from(files("../gradle/libs.versions.toml")) } 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/common-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | // Apply the org.jetbrains.kotlin.jvm Plugin to add support for Kotlin. 3 | id("org.jetbrains.kotlin.jvm") 4 | `java-library` 5 | } 6 | 7 | repositories { 8 | mavenCentral() 9 | } 10 | 11 | java { 12 | toolchain { 13 | languageVersion.set(JavaLanguageVersion.of(21)) 14 | } 15 | sourceCompatibility = JavaVersion.VERSION_21 16 | targetCompatibility = JavaVersion.VERSION_21 17 | } 18 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/publish-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("common-conventions") 3 | `java-library` 4 | `maven-publish` 5 | signing 6 | } 7 | 8 | java { 9 | withSourcesJar() 10 | withJavadocJar() 11 | } 12 | 13 | publishing { 14 | publications { 15 | create("mavenJava") { 16 | from(components["java"]) 17 | pom { 18 | name.set("kotlinx-htmx") 19 | description.set("Kotlinx HTMX extensions") 20 | url.set("https://github.com/IodeSystems/kotlinx-htmx") 21 | licenses { 22 | license { name.set("MIT License") } 23 | } 24 | developers { 25 | developer { 26 | id.set("nthalk") 27 | name.set("Carl Taylor") 28 | email.set("carl@etaylor.me") 29 | } 30 | } 31 | scm { 32 | connection.set("scm:git:git://github.com/IodeSystems/kotlinx-htmx.git") 33 | developerConnection.set("scm:git:ssh://github.com/IodeSystems/kotlinx-htmx.git") 34 | url.set("https://github.com/IodeSystems/kotlinx-htmx") 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | signing { 42 | setRequired { project.version.toString().endsWith("-SNAPSHOT") } 43 | useGpgCmd() 44 | 45 | sign(publishing.publications["mavenJava"]) 46 | } 47 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("io.github.gradle-nexus.publish-plugin") 3 | 4 | } 5 | 6 | nexusPublishing { 7 | repositories { 8 | sonatype() 9 | } 10 | } 11 | 12 | -------------------------------------------------------------------------------- /example/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | // Apply the org.jetbrains.kotlin.jvm Plugin to add support for Kotlin. 3 | id("common-conventions") 4 | 5 | // Apply the java-library plugin for API and implementation separation. 6 | application 7 | } 8 | 9 | repositories { 10 | // Use Maven Central for resolving dependencies. 11 | mavenCentral() 12 | } 13 | 14 | dependencies { 15 | implementation(project(":htmx")) 16 | implementation(project(":spring")) 17 | implementation(libs.kotlinx.html) 18 | implementation(libs.spring.web) 19 | implementation(libs.spring.boot.starter.web) 20 | implementation(libs.spring.boot.starter.websocket) 21 | implementation(libs.spring.boot.starter.json) 22 | implementation(libs.jackson.module.kotlin) 23 | } 24 | 25 | testing { 26 | suites { 27 | // Configure the built-in test suite 28 | val test by getting(JvmTestSuite::class) { 29 | // Use Kotlin Test test framework 30 | useKotlinTest("1.9.20") 31 | } 32 | } 33 | } 34 | 35 | // Apply a specific Java toolchain to ease working on different environments. 36 | java { 37 | toolchain { 38 | languageVersion.set(JavaLanguageVersion.of(21)) 39 | } 40 | } 41 | 42 | kotlin { 43 | compilerOptions { 44 | freeCompilerArgs.add("-Xcontext-receivers") 45 | } 46 | } 47 | 48 | application { 49 | mainClass.set("com.iodesystems.htmx.example.ExampleKt") 50 | } 51 | -------------------------------------------------------------------------------- /example/src/main/kotlin/com/iodesystems/htmx/example/Example.kt: -------------------------------------------------------------------------------- 1 | package com.iodesystems.htmx.example 2 | 3 | import org.springframework.boot.SpringApplication 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | 6 | @SpringBootApplication 7 | open class Example 8 | 9 | fun main() { 10 | SpringApplication.run(Example::class.java) 11 | } 12 | -------------------------------------------------------------------------------- /example/src/main/kotlin/com/iodesystems/htmx/example/config/WebConfig.kt: -------------------------------------------------------------------------------- 1 | package com.iodesystems.htmx.example.config 2 | 3 | import com.iodesystems.htmx.example.HtmxHttpMessageConverter 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.http.converter.HttpMessageConverter 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 7 | 8 | @Configuration 9 | open class WebConfig : WebMvcConfigurer { 10 | override fun extendMessageConverters(converters: MutableList>) { 11 | converters.add(0, HtmxHttpMessageConverter()) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/src/main/kotlin/com/iodesystems/htmx/example/config/WebSocketConfig.kt: -------------------------------------------------------------------------------- 1 | package com.iodesystems.htmx.example.config 2 | 3 | import com.iodesystems.rankor.app.config.sockets.MappedWebSocketHandler 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.web.socket.config.annotation.EnableWebSocket 6 | import org.springframework.web.socket.config.annotation.WebSocketConfigurer 7 | import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry 8 | 9 | @Configuration 10 | @EnableWebSocket 11 | open class WebSocketConfig( 12 | val handlers: List, 13 | ) : WebSocketConfigurer { 14 | 15 | 16 | override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { 17 | handlers.forEach { handler -> 18 | registry.addHandler(handler, handler.mapping).setAllowedOrigins(*handler.allowedOrigins.toTypedArray()) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /example/src/main/kotlin/com/iodesystems/htmx/example/config/sockets/MappedWebSocketHandler.kt: -------------------------------------------------------------------------------- 1 | package com.iodesystems.rankor.app.config.sockets 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.springframework.web.socket.CloseStatus 5 | import org.springframework.web.socket.WebSocketHandler 6 | import org.springframework.web.socket.WebSocketMessage 7 | import org.springframework.web.socket.WebSocketSession 8 | 9 | abstract class MappedWebSocketHandler( 10 | val mapping: String, 11 | val allowedOrigins: List = listOf("*"), 12 | ) : WebSocketHandler { 13 | companion object { 14 | val LOG = LoggerFactory.getLogger(MappedWebSocketHandler::class.java) 15 | } 16 | 17 | override fun afterConnectionEstablished(session: WebSocketSession) { 18 | LOG.trace("afterConnectionEstablished") 19 | } 20 | 21 | override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) { 22 | LOG.trace("handleMessage") 23 | } 24 | 25 | override fun handleTransportError(session: WebSocketSession, exception: Throwable) { 26 | LOG.error("handleTransportError", exception) 27 | } 28 | 29 | override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) { 30 | LOG.trace("afterConnectionClosed") 31 | } 32 | 33 | override fun supportsPartialMessages(): Boolean { 34 | return false 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example/src/main/kotlin/com/iodesystems/htmx/example/controllers/ChatController.kt: -------------------------------------------------------------------------------- 1 | package com.iodesystems.htmx.example.controllers 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.iodesystems.htmx.Htmx 6 | import com.iodesystems.htmx.Htmx.Companion.hx 7 | import com.iodesystems.htmx.HtmxTrigger 8 | import com.iodesystems.rankor.app.config.sockets.MappedWebSocketHandler 9 | import kotlinx.html.* 10 | import org.springframework.beans.factory.annotation.Autowired 11 | import org.springframework.stereotype.Service 12 | import org.springframework.web.bind.annotation.* 13 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter 14 | import org.springframework.web.socket.CloseStatus 15 | import org.springframework.web.socket.TextMessage 16 | import org.springframework.web.socket.WebSocketMessage 17 | import org.springframework.web.socket.WebSocketSession 18 | import java.io.IOException 19 | import kotlin.time.Duration.Companion.seconds 20 | import kotlin.time.toJavaDuration 21 | 22 | @RestController 23 | @RequestMapping("/") 24 | class ChatController( 25 | val chatState: ChatState, 26 | val objectMapper: ObjectMapper 27 | ) { 28 | 29 | @Service 30 | class ChatState { 31 | val messages = mutableListOf() 32 | val sseClients = mutableListOf() 33 | val wsClients = mutableListOf() 34 | } 35 | 36 | @GetMapping("/") 37 | fun index() = Htmx { 38 | html { 39 | header { 40 | script { 41 | src = "https://unpkg.com/htmx.org@1.9.9/dist/htmx.min.js" 42 | } 43 | script { 44 | src = "https://unpkg.com/htmx.org@1.9.9/dist/ext/json-enc.js" 45 | } 46 | style { 47 | unsafe { 48 | raw( 49 | """ 50 | #indicator { 51 | display: none; 52 | } 53 | #indicator.htmx-request{ 54 | display:inline; 55 | } 56 | """.trimIndent() 57 | ) 58 | } 59 | } 60 | } 61 | body { 62 | hx { 63 | // Support json events 64 | json() 65 | // Support websockets 66 | ws("/chat-ws") 67 | // Support server sent events 68 | sse("/chat-sse") 69 | } 70 | 71 | 72 | div { 73 | id = "indicator" 74 | +"Loading..." 75 | } 76 | counterButton().render() 77 | form { 78 | id = "form" 79 | hx { 80 | wsSend("submit") 81 | } 82 | input { 83 | type = InputType.text 84 | name = "message" 85 | } 86 | input { 87 | type = InputType.submit 88 | value = "Send" 89 | } 90 | } 91 | messages().render() 92 | messageCount().render() 93 | } 94 | } 95 | } 96 | 97 | data class Counter(val count: Int) 98 | 99 | @PostMapping("/counter") 100 | fun counterButton( 101 | @RequestBody counter: Counter = Counter(0) 102 | ) = Htmx { 103 | if (counter.count > 0) { 104 | // Let the indicator show! 105 | Thread.sleep(1.seconds.toJavaDuration()) 106 | } 107 | button { 108 | id = "button" 109 | hx { 110 | post("/counter") 111 | swap() 112 | indicator("#indicator") 113 | trigger { 114 | click() 115 | queue(HtmxTrigger.Queue.first) 116 | vals(objectMapper.writeValueAsString(counter.copy(count = counter.count + 1))) 117 | } 118 | } 119 | +"Click Count ${counter.count}" 120 | } 121 | } 122 | 123 | fun messages(loadedWithWs: Boolean = false) = Htmx { 124 | div { 125 | id = "messages" 126 | if (loadedWithWs) { 127 | div { 128 | +"Loaded with WS:" 129 | } 130 | } 131 | chatState.messages.forEach { 132 | div { 133 | +it 134 | } 135 | } 136 | } 137 | } 138 | 139 | fun messageCount(loadedWithSse: Boolean = false) = Htmx { 140 | div { 141 | hx { 142 | sseSwap("message") 143 | } 144 | id = "message-count" 145 | if (loadedWithSse) { 146 | div { 147 | +"Loaded with SSE:" 148 | } 149 | } 150 | +"${chatState.messages.size} messages" 151 | } 152 | } 153 | 154 | @GetMapping("/chat-sse") 155 | fun chatSse(): SseEmitter { 156 | val emitter = SseEmitter(Long.MAX_VALUE) 157 | chatState.wsClients.add(emitter) 158 | emitter.onCompletion { 159 | chatState.wsClients.remove(emitter) 160 | } 161 | return emitter 162 | } 163 | 164 | @Service 165 | class ChatWebSocketHandler( 166 | val objectMapper: ObjectMapper, 167 | val chatState: ChatState 168 | ) : MappedWebSocketHandler("/chat-ws") { 169 | 170 | @JsonIgnoreProperties(ignoreUnknown = true) 171 | data class Message(val message: String) 172 | 173 | // Late init because of circular dependency 174 | // We want to re-use the controller to build responses 175 | @Autowired 176 | lateinit var chatController: ChatController 177 | 178 | override fun afterConnectionEstablished(session: WebSocketSession) { 179 | chatState.sseClients.add(session) 180 | } 181 | 182 | override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) { 183 | chatState.sseClients.remove(session) 184 | } 185 | 186 | override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) { 187 | // Parse and apply state 188 | val parsed = objectMapper.readValue(message.payload as String, Message::class.java) 189 | chatState.messages.add(parsed.message) 190 | 191 | // Build responses 192 | val wsMessage = chatController.messages(true).toString() 193 | val sseMessage = SseEmitter.event() 194 | .name("message") 195 | .data(chatController.messageCount(true).toString()) 196 | 197 | // Send to ws clients 198 | val failed = mutableListOf() 199 | chatState.wsClients.forEach { 200 | try { 201 | it.send(sseMessage) 202 | } catch (e: IOException) { 203 | failed.add(it) 204 | } 205 | } 206 | failed.forEach { 207 | chatState.wsClients.remove(it) 208 | } 209 | 210 | // Send to sse clients 211 | chatState.sseClients.forEach { client -> 212 | client.sendMessage(TextMessage(wsMessage)) 213 | } 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # This file was generated by the Gradle 'init' task. 2 | # https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties 3 | 4 | org.gradle.parallel=true 5 | org.gradle.caching=true 6 | 7 | group=com.iodesystems.kotlinx-htmx 8 | version=0.0.2-SNAPSHOT 9 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | ## Generated by $ ./gradlew refreshVersionsCatalog 2 | 3 | [plugins] 4 | 5 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 6 | 7 | io-github-gradle-nexus-publish-plugin = { id = "io.github.gradle-nexus.publish-plugin", version = "2.0.0" } 8 | 9 | [versions] 10 | 11 | spring = "6.2.5" 12 | spring-boot = "3.4.4" 13 | jackson = "2.16.0" 14 | kotlin = "2.1.20" 15 | kotlinx-html = "0.12.0" 16 | 17 | [libraries] 18 | 19 | kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html-jvm", version.ref = "kotlinx-html" } 20 | spring-web = { module = "org.springframework:spring-web", version.ref = "spring" } 21 | spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } 22 | spring-boot-starter-websocket = { module = "org.springframework.boot:spring-boot-starter-websocket", version.ref = "spring-boot" } 23 | spring-boot-starter-json = "org.springframework.boot:spring-boot-starter-json:3.4.4" 24 | jackson-module-kotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:2.18.3" 25 | gradle-plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 26 | gradle-plugin-nexus-publish = "io.github.gradle-nexus:publish-plugin:2.0.0" 27 | kotlinx-html-jvm = { group = "org.jetbrains.kotlinx", name = "kotlinx-html-jvm", version.ref = "kotlinx-html" } 28 | kotlin-scripting-compiler-embeddable = { group = "org.jetbrains.kotlin", name = "kotlin-scripting-compiler-embeddable", version.ref = "kotlin" } 29 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IodeSystems/kotlinx-htmx/328e439ce4510ab57a99f7a3d9242e4a439e148b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /htmx/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("publish-conventions") 3 | } 4 | 5 | dependencies { 6 | implementation(libs.kotlinx.html) 7 | } 8 | 9 | kotlin { 10 | compilerOptions { 11 | freeCompilerArgs.add("-Xcontext-receivers") 12 | } 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /htmx/src/main/kotlin/com.iodesystems.htmx/Htmx.kt: -------------------------------------------------------------------------------- 1 | package com.iodesystems.htmx 2 | 3 | import kotlinx.html.FlowContent 4 | import kotlinx.html.TagConsumer 5 | import kotlinx.html.stream.appendHTML 6 | import java.io.ByteArrayOutputStream 7 | 8 | data class Htmx(val block: TagConsumer<*>.() -> Unit) { 9 | context(FlowContent) 10 | fun render() = block.invoke(consumer) 11 | 12 | override fun toString(): String { 13 | val baos = ByteArrayOutputStream() 14 | val writer = baos.writer() 15 | writer.appendHTML().block() 16 | writer.close() 17 | return baos.toString() 18 | } 19 | 20 | companion object { 21 | fun FlowContent.hx(block: HtmxAttrs.() -> Unit) { 22 | block(HtmxAttrs(this)) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /htmx/src/main/kotlin/com.iodesystems.htmx/HtmxAttrs.kt: -------------------------------------------------------------------------------- 1 | package com.iodesystems.htmx 2 | 3 | import kotlinx.html.FlowContent 4 | import org.intellij.lang.annotations.Language 5 | import kotlin.time.Duration 6 | 7 | data class HtmxAttrs(val tag: FlowContent) { 8 | /** 9 | * The hx-post attribute allows you to specify the URL that will be used for an AJAX request. 10 | * See: https://htmx.org/attributes/hx-post/ 11 | */ 12 | fun post(path: String) { 13 | tag.attributes["hx-post"] = path 14 | } 15 | 16 | /** 17 | * The hx-ext attribute enables an htmx extension for an element and all its children. 18 | * 19 | * See: https://htmx.org/attributes/hx-ext/ 20 | */ 21 | fun ext(extension: String) { 22 | tag.attributes["hx-ext"] = extension 23 | } 24 | 25 | /** 26 | * Enable the JSON encoding extension. 27 | */ 28 | fun json() { 29 | tag.attributes["hx-ext"] = "json-enc" 30 | } 31 | 32 | /** 33 | * The hx-put attribute allows you to specify the URL that will be used for an AJAX request. 34 | * See: https://htmx.org/attributes/hx-put/ 35 | */ 36 | fun put(path: String) { 37 | tag.attributes["hx-put"] = path 38 | } 39 | 40 | /** 41 | * The hx-patch attribute allows you to specify the URL that will be used for an AJAX request. 42 | * See: https://htmx.org/attributes/hx-patch/ 43 | */ 44 | fun patch(path: String) { 45 | tag.attributes["hx-patch"] = path 46 | } 47 | 48 | /** 49 | * The hx-get attribute allows you to specify the URL that will be used for an AJAX request. 50 | * See: https://htmx.org/attributes/hx-get/ 51 | */ 52 | fun get(path: String) { 53 | tag.attributes["hx-get"] = path 54 | } 55 | 56 | /** 57 | * The hx-delete attribute allows you to specify the URL that will be used for an AJAX request. 58 | * See: https://htmx.org/attributes/hx-delete/ 59 | */ 60 | fun delete(path: String) { 61 | tag.attributes["hx-delete"] = path 62 | } 63 | 64 | /** 65 | * The hx-trigger attribute allows you to specify what triggers an AJAX request. 66 | * See: https://htmx.org/attributes/hx-trigger/ 67 | */ 68 | fun trigger(block: HtmxTrigger.() -> Unit) { 69 | block(HtmxTrigger(tag)) 70 | } 71 | 72 | 73 | /** 74 | * The hx-vals attribute allows you to specify a JSON object that will be sent as the body of the request. 75 | * 76 | * See: https://htmx.org/attributes/hx-vals/ 77 | */ 78 | fun vals(obj: String) { 79 | tag.attributes["hx-vals"] = obj 80 | } 81 | 82 | /** 83 | * The hx-ws allows you to work with Web Sockets directly from HTML. 84 | * 85 | * See: https://htmx.org/attributes/hx-ws/ 86 | */ 87 | fun ws(path: String) { 88 | tag.attributes["hx-ws"] = "connect:$path" 89 | } 90 | 91 | /** 92 | * The hx-ws allows you to work with Web Sockets directly from HTML. 93 | * 94 | * See: https://htmx.org/attributes/hx-ws/ 95 | */ 96 | fun wsSend(event: String) { 97 | tag.attributes["hx-ws"] = "send:$event" 98 | } 99 | 100 | // Server Sent Events 101 | fun sse(path: String, swapEvent: String? = null) { 102 | tag.attributes["hx-sse"] = "connect:$path" 103 | if (swapEvent != null) { 104 | tag.attributes["hx-sse"] += " swap:$swapEvent" 105 | } 106 | } 107 | 108 | /** 109 | * When using sse, this will swap the element with the response from the server. 110 | * 111 | * See: https://htmx.org/attributes/hx-sse/ 112 | */ 113 | fun sseSwap(event: String) { 114 | tag.attributes["hx-sse"] = "swap:$event" 115 | } 116 | 117 | 118 | /** 119 | * The hx-request attribute allows you to specify additional request parameters. 120 | * See: https://htmx.org/attributes/hx-request/ 121 | */ 122 | fun request( 123 | timeout: Duration? = null, 124 | noHeaders: Boolean? = null, 125 | credentials: Boolean? = null, 126 | ) { 127 | var request = "" 128 | if (timeout != null) { 129 | request += "\"timeout\":${timeout.inWholeMilliseconds}" 130 | } 131 | if (noHeaders != null) { 132 | if (request.isNotEmpty()) { 133 | request += "," 134 | } 135 | request += "\"noHeaders\":$noHeaders" 136 | } 137 | if (credentials != null) { 138 | if (request.isNotEmpty()) { 139 | request += "," 140 | } 141 | request += "\"credentials\":$credentials" 142 | } 143 | tag.attributes["hx-request"] = request 144 | } 145 | 146 | enum class Scroll { 147 | TOP, BOTTOM 148 | } 149 | 150 | enum class Swap { 151 | outerHTML, 152 | innerHTML, 153 | afterbegin, 154 | beforebegin, 155 | afterend, 156 | beforeend, 157 | none, 158 | morphdom, 159 | } 160 | 161 | /** 162 | * Swap the element with the response from the server. 163 | * 164 | * See: https://htmx.org/attributes/hx-swap-oob/ 165 | */ 166 | fun swapOob( 167 | mode: Swap = Swap.outerHTML, 168 | transition: Boolean = false, 169 | swap: Duration? = null, 170 | settle: Duration? = null, 171 | ignorTitle: Boolean = false, 172 | scroll: Scroll? = null, 173 | ) { 174 | return swap( 175 | mode = mode, 176 | transition = transition, 177 | swap = swap, 178 | settle = settle, 179 | ignorTitle = ignorTitle, 180 | scroll = scroll, 181 | oob = true 182 | ) 183 | } 184 | 185 | /** 186 | * Swap the element with the response from the server. 187 | * 188 | * See: https://htmx.org/attributes/hx-swap/ 189 | */ 190 | fun swap( 191 | mode: Swap = Swap.outerHTML, 192 | transition: Boolean = false, 193 | swap: Duration? = null, 194 | settle: Duration? = null, 195 | ignorTitle: Boolean = false, 196 | scroll: Scroll? = null, 197 | show: Scroll? = null, 198 | oob: Boolean = false 199 | ) { 200 | var setting = mode.name 201 | if (transition) { 202 | setting += " transition:true" 203 | } 204 | if (settle != null) { 205 | setting += " settle:${settle.inWholeMilliseconds}ms" 206 | } 207 | if (ignorTitle) { 208 | setting += " ignoreTitle:true" 209 | } 210 | if (scroll != null) { 211 | setting += " scroll:${scroll.name.lowercase()}" 212 | } 213 | if (show != null) { 214 | setting += " show:${show.name.lowercase()}" 215 | } 216 | if (swap != null) { 217 | setting += " swap:${swap.inWholeMilliseconds}ms" 218 | } 219 | tag.attributes["hx-swap${if (oob) "-oob" else ""}"] = setting 220 | } 221 | 222 | /** 223 | * Simple onclick handler that binds with htmx.trigger 224 | */ 225 | fun click(selector: String, event: String = "") { 226 | tag.attributes["onclick"] = "htmx.trigger('$selector', '$event')" 227 | } 228 | 229 | enum class FormEncoding(val value: String) { 230 | URL_ENCODED("application/x-www-form-urlencoded"), 231 | MULTIPART_FORM_DATA("multipart/form-data"), 232 | } 233 | 234 | /** 235 | * changes the request encoding type 236 | * See: https://htmx.org/attributes/hx-encoding/ 237 | */ 238 | fun encoding(encoding: FormEncoding) { 239 | tag.attributes["hx-encoding"] = encoding.value 240 | } 241 | 242 | /** 243 | * The hx-indicator attribute allows you to specify the element that will have the htmx-request class added to it for the duration of the request. This can be used to show spinners or progress indicators while the request is in flight. 244 | * See: https://htmx.org/attributes/hx-indicator/ 245 | */ 246 | fun indicator(selector: String) { 247 | tag.attributes["hx-indicator"] = selector 248 | } 249 | 250 | /** 251 | * The hx-target attribute allows you to target a different element for swapping than the one issuing the AJAX request. 252 | * 253 | * See: https://htmx.org/attributes/hx-target/ 254 | */ 255 | fun target(selector: String) { 256 | tag.attributes["hx-target"] = selector 257 | } 258 | 259 | /** 260 | * Add or remove progressive enhancement for links and forms 261 | * See: https://htmx.org/attributes/hx-boost/ 262 | */ 263 | fun boost(enabled: Boolean = true) { 264 | tag.attributes["hx-boost"] = enabled.toString() 265 | } 266 | 267 | /** 268 | * Pushes the URL into the browser location bar, creating a new history entry 269 | * See: https://htmx.org/attributes/hx-push-url/ 270 | */ 271 | fun pushUrl(enabled: Boolean) { 272 | tag.attributes["hx-push-url"] = enabled.toString() 273 | } 274 | 275 | /** 276 | * Pushes the URL into the browser location bar, creating a new history entry 277 | * See: https://htmx.org/attributes/hx-push-url/ 278 | */ 279 | fun pushUrl(url: String) { 280 | tag.attributes["hx-push-url"] = url 281 | } 282 | 283 | /** 284 | * The hx-replace-url attribute allows you to replace the current url of the browser location history. 285 | * See: https://htmx.org/attributes/hx-replace-url/ 286 | */ 287 | fun replaceUrl(enabled: Boolean) { 288 | tag.attributes["hx-replace-url"] = enabled.toString() 289 | } 290 | 291 | /** 292 | * The hx-replace-url attribute allows you to replace the current url of the browser location history. 293 | * See: https://htmx.org/attributes/hx-replace-url/ 294 | */ 295 | fun replaceUrl(url: String) { 296 | tag.attributes["hx-replace-url"] = url 297 | } 298 | 299 | /** 300 | * Set the hx-history attribute to false on any element in the current document, or any html fragment loaded into the current document by htmx, to prevent sensitive data being saved to the localStorage cache when htmx takes a snapshot of the page state. 301 | * 302 | * See: https://htmx.org/attributes/hx-history/ 303 | */ 304 | fun history(enabled: Boolean) { 305 | tag.attributes["hx-history"] = enabled.toString() 306 | } 307 | 308 | 309 | /** 310 | * the element to snapshot and restore during history navigation 311 | * 312 | * See: https://htmx.org/attributes/hx-history-elt/ 313 | */ 314 | fun historyElt() { 315 | tag.attributes["hx-history-elt"] = "true" 316 | } 317 | 318 | /** 319 | * handle any event with a script inline 320 | * 321 | * See: https://htmx.org/attributes/hx-on/ 322 | */ 323 | fun on( 324 | event: String, 325 | @Language("JavaScript") 326 | code: String 327 | ) { 328 | tag.attributes["hx-on:$event"] = code 329 | } 330 | 331 | /** 332 | * The hx-params attribute allows you to filter the parameters that will be submitted with an AJAX request. 333 | * 334 | * See: https://htmx.org/attributes/hx-params/ 335 | */ 336 | fun params(vararg params: String) { 337 | tag.attributes["hx-params"] = params.joinToString(",") 338 | } 339 | 340 | /** 341 | * Select the element to swap with the response from the server. 342 | * See: https://htmx.org/attributes/hx-select/ 343 | */ 344 | fun select(selector: String) { 345 | tag.attributes["hx-select"] = selector 346 | } 347 | 348 | /** 349 | * Select the element to swap with the response from the server. 350 | * See: https://htmx.org/attributes/hx-select-oob/ 351 | */ 352 | fun selectOob(selector: String) { 353 | tag.attributes["hx-select-oob"] = selector 354 | } 355 | 356 | /** 357 | * The hx-disable attribute will disable htmx processing for a given element and all its children. This can be useful as a backup for HTML escaping, when you include user generated content in your site, and you want to prevent malicious scripting attacks. 358 | * 359 | * The value of the tag is ignored, and it cannot be reversed by any content beneath it. 360 | * See: https://htmx.org/attributes/hx-boosted/ 361 | */ 362 | fun disable() { 363 | tag.attributes["hx-disabled"] = "true" 364 | } 365 | 366 | /** 367 | * The hx-disabled-elt attribute allows you to specify elements that will have the disabled attribute added to them for the duration of the request. 368 | * See: https://htmx.org/attributes/hx-disabled-elt/ 369 | */ 370 | fun disableElt() { 371 | tag.attributes["hx-disabled-elt"] = "true" 372 | } 373 | 374 | /** 375 | * The default behavior for htmx is to “inherit” many attributes automatically: that is, an attribute such as hx-target may be placed on a parent element, and all child elements will inherit that target. 376 | * 377 | * See: https://htmx.org/attributes/hx-disinherit/ 378 | */ 379 | fun disinherit(vararg selector: String = arrayOf("*")) { 380 | tag.attributes["hx-disinherit"] = selector.joinToString(" ") 381 | } 382 | 383 | /** 384 | * The hx-headers attribute allows you to add to the headers that will be submitted with an AJAX request. 385 | * 386 | * See: https://htmx.org/attributes/hx-headers/ 387 | */ 388 | fun headers(map: Map) { 389 | tag.attributes["hx-headers"] = map.entries.joinToString(",") { (key, value) -> 390 | val escapedValue = value.replace("\"", "\\\"") 391 | "\"$key\":\"$escapedValue\"" 392 | } 393 | } 394 | 395 | /** 396 | * The hx-include attribute allows you to include additional element values in an AJAX request. 397 | * See: https://htmx.org/attributes/hx-include/ 398 | */ 399 | fun include(selector: String) { 400 | tag.attributes["hx-include"] = selector 401 | } 402 | 403 | /** 404 | * The hx-preserve attribute allows you to keep an element unchanged during HTML replacement. Elements with hx-preserve set are preserved by id when htmx updates any ancestor element. You must set an unchanging id on elements for hx-preserve to work. The response requires an element with the same id, but its type and other attributes are ignored. 405 | * See: https://htmx.org/attributes/hx-preserve/ 406 | */ 407 | fun preserve() { 408 | tag.attributes["hx-preserve"] = "true" 409 | } 410 | 411 | } 412 | -------------------------------------------------------------------------------- /htmx/src/main/kotlin/com.iodesystems.htmx/HtmxTrigger.kt: -------------------------------------------------------------------------------- 1 | package com.iodesystems.htmx 2 | 3 | import kotlinx.html.FlowContent 4 | import kotlin.time.Duration 5 | 6 | data class HtmxTrigger(val tag: FlowContent) { 7 | 8 | fun event(event: String) { 9 | if (tag.attributes["hx-trigger"] == null) { 10 | tag.attributes["hx-trigger"] = event 11 | } else { 12 | tag.attributes["hx-trigger"] += ",$event" 13 | } 14 | } 15 | 16 | fun click() = event("click") 17 | 18 | fun mouseenter() = event("mouseenter") 19 | 20 | fun mouseleave() = event("mouseleave") 21 | 22 | /** 23 | * triggered when an element is scrolled into the viewport (also useful for lazy-loading). If you are using overflow in css like overflow-y: scroll you should use intersect once instead of revealed. 24 | */ 25 | fun revealed() = event("revealed") 26 | 27 | /** 28 | * fires once when an element first intersects the viewport. 29 | */ 30 | fun intersects( 31 | selector: String? = null, 32 | threshold: Float? = null, 33 | ) { 34 | var trigger = "intersects" 35 | if (selector != null) { 36 | trigger += "root:$selector" 37 | } 38 | if (threshold != null) { 39 | trigger += "threshold:$threshold" 40 | } 41 | event(trigger) 42 | } 43 | 44 | /** 45 | * triggered on load (useful for lazy-loading something) 46 | */ 47 | fun load() = event("load") 48 | 49 | fun sse(event: String) = event("sse:$event") 50 | 51 | fun every(time: Duration) = event("every ${time.inWholeMilliseconds}ms") 52 | 53 | fun keyup() = event("keyup") 54 | 55 | fun withKey(key: String) { 56 | tag.attributes["hx-trigger"] += "[$key]" 57 | } 58 | 59 | /** 60 | * the event will only trigger once (e.g. the first click) 61 | */ 62 | fun once() { 63 | tag.attributes["hx-trigger"] += " once" 64 | } 65 | 66 | /** 67 | * the event will only change if the value of the element has changed. Please pay attention change is the name of the event and changed is the name of the modifier. 68 | */ 69 | fun changed() { 70 | tag.attributes["hx-trigger"] += " changed" 71 | } 72 | 73 | /** 74 | * a delay will occur before an event triggers a request. If the event is seen again it will reset the delay. 75 | * See: https://htmx.org/attributes/hx-trigger/ 76 | */ 77 | fun delay(duration: Duration) { 78 | tag.attributes["hx-trigger"] += " delay:${duration.inWholeMilliseconds}ms" 79 | } 80 | 81 | /** 82 | * a throttle will occur after an event triggers a request. If the event is seen again before the delay completes, it is ignored, the element will trigger at the end of the delay. 83 | * See: https://htmx.org/attributes/hx-trigger/ 84 | */ 85 | fun throttle(duration: Duration) { 86 | tag.attributes["hx-trigger"] += " throttle:${duration.inWholeMilliseconds}ms" 87 | } 88 | 89 | 90 | /** 91 | * allows the event that triggers a request to come from another element in the document (e.g. listening to a key event on the body, to support hot keys) 92 | * See: https://htmx.org/attributes/hx-trigger/ 93 | */ 94 | fun from(selector: String) { 95 | tag.attributes["hx-trigger"] += " from:$selector" 96 | } 97 | 98 | /** 99 | * allows you to filter via a CSS selector on the target of the event. This can be useful when you want to listen for triggers from elements that might not be in the DOM at the point of initialization, by, for example, listening on the body, but with a target filter for a child element 100 | */ 101 | fun target(selector: String) { 102 | tag.attributes["hx-trigger"] += " target:$selector" 103 | } 104 | 105 | enum class Queue { 106 | first, 107 | last, 108 | all, 109 | none 110 | } 111 | 112 | /** 113 | * etermines how events are queued if an event occurs while a request for another event is in flight 114 | * See: https://htmx.org/attributes/hx-trigger/ 115 | */ 116 | fun queue(mode: Queue) { 117 | tag.attributes["hx-trigger"] += " queue:${mode.name}" 118 | } 119 | 120 | /** 121 | * if this option is included the event will not trigger any other htmx requests on parents (or on elements listening on parents) 122 | * See: https://htmx.org/attributes/hx-trigger/ 123 | */ 124 | fun consume() { 125 | if (tag.attributes["hx-trigger"] == null) { 126 | tag.attributes["hx-trigger"] = "consume" 127 | } else { 128 | tag.attributes["hx-trigger"] += " consume" 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | import de.fayard.refreshVersions.core.StabilityLevel 2 | 3 | 4 | pluginManagement { 5 | includeBuild("build-logic") 6 | 7 | } 8 | 9 | plugins { 10 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" 11 | id("de.fayard.refreshVersions") version "0.60.5" 12 | } 13 | 14 | refreshVersions { 15 | rejectVersionIf { 16 | if (!candidate.stabilityLevel.isAtLeastAsStableAs(StabilityLevel.Stable)) return@rejectVersionIf true 17 | if (candidate.value.contains("rc", ignoreCase = true)) return@rejectVersionIf true 18 | false 19 | } 20 | } 21 | 22 | 23 | rootProject.name = "kotlinx-htmx" 24 | include("htmx", "spring", "example") 25 | -------------------------------------------------------------------------------- /spring/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("publish-conventions") 3 | } 4 | 5 | dependencies { 6 | implementation(project(":htmx")) 7 | implementation(libs.kotlinx.html) 8 | implementation(libs.spring.web) 9 | } 10 | -------------------------------------------------------------------------------- /spring/src/main/kotlin/com/iodesystems/htmx/example/HtmxMessageConverter.kt: -------------------------------------------------------------------------------- 1 | package com.iodesystems.htmx.example 2 | 3 | import com.iodesystems.htmx.Htmx 4 | import kotlinx.html.stream.appendHTML 5 | import org.springframework.http.HttpInputMessage 6 | import org.springframework.http.HttpOutputMessage 7 | import org.springframework.http.MediaType 8 | import org.springframework.http.converter.HttpMessageConverter 9 | 10 | class HtmxHttpMessageConverter : HttpMessageConverter { 11 | 12 | override fun canRead(clazz: Class<*>, mediaType: MediaType?): Boolean { 13 | return false 14 | } 15 | 16 | override fun read(clazz: Class, inputMessage: HttpInputMessage): Htmx { 17 | throw UnsupportedOperationException("Clients don't send html to us, c'mon!") 18 | } 19 | 20 | override fun canWrite(clazz: Class<*>, mediaType: MediaType?): Boolean { 21 | return Htmx::class.java.isAssignableFrom(clazz) && ( 22 | mediaType == null 23 | || mediaType == MediaType.TEXT_HTML 24 | ) 25 | } 26 | 27 | override fun getSupportedMediaTypes(): MutableList { 28 | return mutableListOf(MediaType.TEXT_HTML) 29 | } 30 | 31 | override fun write(t: Htmx, contentType: MediaType?, outputMessage: HttpOutputMessage) { 32 | outputMessage.headers.contentType = MediaType.TEXT_HTML 33 | val writer = outputMessage.body.writer() 34 | t.block(writer.appendHTML()) 35 | writer.close() 36 | 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /versions.properties: -------------------------------------------------------------------------------- 1 | #### Dependencies and Plugin versions with their available updates. 2 | #### Generated by `./gradlew refreshVersions` version 0.51.0 3 | #### 4 | #### Don't manually edit or split the comments that start with four hashtags (####), 5 | #### they will be overwritten by refreshVersions. 6 | #### 7 | #### suppress inspection "SpellCheckingInspection" for whole file 8 | #### suppress inspection "UnusedProperty" for whole file 9 | #### 10 | #### NOTE: Some versions are filtered by the rejectVersionsIf predicate. See the settings.gradle.kts file. 11 | 12 | plugin.io.github.gradle-nexus.publish-plugin=1.3.0 13 | 14 | ## unused 15 | plugin.de.fayard.refreshVersions=0.51.0 16 | 17 | ## unused 18 | plugin.org.gradle.toolchains.foojay-resolver-convention=0.7.0 19 | 20 | ## unused 21 | version.org.springframework.boot..spring-boot-starter-websocket=3.2.1 22 | 23 | ## unused 24 | version.org.springframework.boot..spring-boot-starter-web=3.2.1 25 | 26 | ## unused 27 | version.org.springframework.boot..spring-boot-starter-json=3.2.1 28 | 29 | ## unused 30 | version.org.springframework..spring-web=6.1.2 31 | 32 | ## unused 33 | version.kotlinx.html=0.10.1 34 | 35 | ## unused 36 | version.kotlin=1.9.20 37 | 38 | ## unused 39 | version.com.fasterxml.jackson.module..jackson-module-kotlin=2.16.0 40 | --------------------------------------------------------------------------------