Mojolicious-Plugin-HTMX/lib/Mojolicious/Plugin/HTMX.pm
package Mojolicious::Plugin::HTMX;
use Mojo::Base 'Mojolicious::Plugin';
use Mojo::ByteStream;
use Mojo::JSON qw(encode_json decode_json);
use Mojo::Util qw(xml_escape);
our $VERSION = '1.03';
my @HX_RESWAPS = (qw[
innerHTML
outerHTML
beforebegin
afterbegin
beforeend
afterend
delete
none
]);
use constant HX_TRUE => 'true';
use constant HX_FALSE => 'false';
use constant HTMX_STOP_POLLING => 286;
use constant HTMX_CDN_URL => 'https://unpkg.com/htmx.org';
use constant HTMX_V1_CDN_URL => 'https://unpkg.com/htmx.org@1.9.12';
sub register {
my ($self, $app) = @_;
$app->helper('is_htmx_request' => sub { _header(shift, 'HX-Request', HX_TRUE) });
$app->helper('htmx.asset' => \&_htmx_js);
$app->helper('htmx.stop_polling' => sub { shift->rendered(HTMX_STOP_POLLING) });
$app->helper('htmx.req.boosted' => sub { _header(shift, 'HX-Boosted', HX_TRUE) });
$app->helper('htmx.req.current_url' => sub { Mojo::URL->new(_header(shift, 'HX-Current-URL')) });
$app->helper('htmx.req.history_restore_request' => sub { _header(shift, 'HX-History-Restore-Request', HX_TRUE) });
$app->helper('htmx.req.prompt' => sub { _header(shift, 'HX-Prompt') });
$app->helper('htmx.req.request' => sub { _header(shift, 'HX-Request', HX_TRUE) });
$app->helper('htmx.req.target' => sub { _header(shift, 'HX-Target') });
$app->helper('htmx.req.trigger_name' => sub { _header(shift, 'HX-Trigger-Name') });
$app->helper('htmx.req.trigger' => sub { _header(shift, 'HX-Trigger') });
$app->helper(
'htmx.req.triggering_event' => sub {
eval { decode_json(_header(shift, 'Triggering-Event')) } || {};
}
);
$app->helper(
'htmx.req.to_hash' => sub {
my $c = shift;
my %hash = ();
my @helpers
= (
qw[boosted current_url history_restore_request prompt request target trigger_name trigger triggering_event]
);
for my $helper (@helpers) {
if (my $value = $c->helpers->htmx->req->$helper()) {
$hash{$helper} = "$value";
}
}
return \%hash;
}
);
$app->helper('htmx.res.location' => \&_res_location);
$app->helper('htmx.res.push_url' => \&_res_push_url);
$app->helper('htmx.res.redirect' => \&_res_redirect);
$app->helper('htmx.res.refresh' => \&_res_refresh);
$app->helper('htmx.res.replace_url' => \&_res_replace_url);
$app->helper('htmx.res.reswap' => \&_res_reswap);
$app->helper('htmx.res.reselect' => \&_res_reselect);
$app->helper('htmx.res.retarget' => \&_res_retarget);
$app->helper('htmx.res.trigger' => sub { _res_trigger('default', @_) });
$app->helper('htmx.res.trigger_after_settle' => sub { _res_trigger('after_settle', @_) });
$app->helper('htmx.res.trigger_after_swap' => sub { _res_trigger('after_swap', @_) });
$app->helper(
'hx' => sub {
my ($c, %attrs) = @_;
my $hx = {};
@$hx{map { y/_/-/; "hx-$_" } keys %attrs} = values %attrs;
return %{$hx};
}
);
$app->helper(
'hx_attr' => sub {
my ($c, %attrs) = @_;
my %hx = $c->hx(%attrs);
my $result = '';
for my $attr (sort keys %hx) {
my $value = $hx{$attr};
$result .= qq{ $attr="} . xml_escape($value) . '"';
}
return Mojo::ByteStream->new($result);
}
);
}
sub _htmx_js {
my ($self, %params) = @_;
my $url = delete $params{url} || HTMX_CDN_URL;
my $ext = delete $params{ext};
if ($ext) {
$url .= "/dist/ext/$ext.js";
}
return Mojo::ByteStream->new(Mojo::DOM::HTML::tag_to_html('script', 'src' => $url));
}
sub _header {
my ($c, $header, $check) = @_;
my $value = $c->req->headers->header($header);
if ($value && $check) {
return !!1 if ($value eq $check);
return !!0;
}
return $value;
}
sub _res_location {
my $c = shift;
my $location = (@_ > 1) ? {@_} : $_[0];
return undef unless $location;
if (ref $location eq 'HASH') {
$location = encode_json($location);
}
return $c->res->headers->header('HX-Location' => $location);
}
sub _res_push_url {
my ($c, $push_url) = @_;
return undef unless $push_url;
return $c->res->headers->header('HX-Push-Url' => $push_url);
}
sub _res_redirect {
my ($c, $redirect) = @_;
return undef unless $redirect;
return $c->res->headers->header('HX-Redirect' => $redirect);
}
sub _res_refresh {
my ($c) = @_;
return $c->res->headers->header('HX-Refresh' => HX_TRUE);
}
sub _res_replace_url {
my ($c, $replace_url) = @_;
return undef unless $replace_url;
return $c->res->headers->header('HX-Replace-Url' => $replace_url);
}
sub _res_reswap {
my ($c, $reswap) = @_;
return undef unless $reswap;
my $is_reswap = grep {/^$reswap$/} @HX_RESWAPS;
Carp::croak "Unknown reswap value" if (!$is_reswap);
return $c->res->headers->header('HX-Reswap' => $reswap);
}
sub _res_reselect {
my ($c, $reselect) = @_;
return undef unless $reselect;
return $c->res->headers->header('HX-Reselect' => $reselect);
}
sub _res_retarget {
my ($c, $retarget) = @_;
return undef unless $retarget;
return $c->res->headers->header('HX-Retarget' => $retarget);
}
sub _res_trigger {
my ($type, $c) = (shift, shift);
my $trigger = (@_ > 1) ? {@_} : $_[0];
return undef unless $trigger;
my $trigger_header = {after_settle => 'HX-Trigger-After-Settle', after_swap => 'HX-Trigger-After-Swap'};
if (ref $trigger eq 'HASH') {
$trigger = encode_json($trigger);
}
if (ref $trigger eq 'ARRAY') {
$trigger = join ', ', @{$trigger};
}
my $header = $trigger_header->{$type} || 'HX-Trigger';
return $c->res->headers->header($header => $trigger);
}
1;
=encoding utf8
=head1 NAME
Mojolicious::Plugin::HTMX - Mojolicious Plugin for htmx
=head1 SYNOPSIS
# Mojolicious
$self->plugin('Mojolicious::Plugin::HTMX');
# Mojolicious::Lite
plugin 'Mojolicious::Plugin::HTMX';
get '/trigger' => 'trigger';
post '/trigger' => sub ($c) {
state $count = 0;
$count++;
$c->htmx->res->trigger(showMessage => 'Here Is A Message');
$c->render(text => "Triggered $count times");
};
@@ template.html.ep
<html>
<head>
%= app->htmx->asset
</head>
<body>
%= content
</body>
</html>
@@ trigger.html.ep
% layout 'default';
<h1>Trigger</h1>
<!-- Common -->
<button hx-post="/trigger">Click Me</button>
<!-- Use "hx" helper -->
%= tag 'button', hx(post => '/trigger'), 'Click Me'
<!-- Use "hx_attr" helper -->
<button <%= hx_attr(post => '/trigger') %>>Click Me</button>
<script>
document.body.addEventListener("showMessage", function(e){
alert(e.detail.value);
});
</script>
=head1 DESCRIPTION
L<Mojolicious::Plugin::HTMX> is a L<Mojolicious> plugin to add htmx in your Mojolicious application.
=head1 HELPERS
L<Mojolicious::Plugin::HTMX> implements the following helpers.
=head2 GENERIC HELPERS
=head3 htmx->asset
# Load htmx from "https://unpkg.com/htmx.org"
%= htmx->asset
# Load htmx from a provided URL
%= htmx->asset(src => '/assets/js/htmx.min.js')
# Load an extension from "/dist/ext" directory
%= htmx->asset(ext => 'debug')
Generate C<script> tag for include htmx script file in your template.
=head3 htmx->stop_polling
Sets the HTTP status code to C<286> which is used by HTMX to halt polling requests.
$c->htmx->stop_polling;
=head3 is_htmx_request
if ($c->is_htmx_request) {
# ...
}
Based on C<HX-Request> header.
=head3 hx
%= tag 'button', hx(get => '/confirm', confirm => 'Confirm The Action'), 'Click For Confirm'
%= button_to Save => 'some_route', hx(patch => url_for('some_route'), swap => 'outerHTML', target => 'body')
C<hx> helper convert the provided HASH attributes in "hx-" format. C<hx> helper is useful when use L<Mojolicious::Plugin::TagHelpers> helpers.
=head3 hx_attr
Alias for L<hx>.
C<hx_attr> helper convert the HASH atteibutes in "hx-" format and generate a well-format string.
<button <%= hx_attr(get => '/confirm', confirm => 'Confirm The Action') %>>Click For Confirm</button>
is equivalent to:
<button hx-get="/confirm" hx-confirm="Confirm The Action">Click For Confirm</button>
=head2 REQUEST HELPERS
=head3 htmx->req->boosted
Indicates that the request is via an element using C<hx-boost>.
Based on C<HX-Boosted> header.
=head3 htmx->req->current_url
The current URL of the browser.
Based on C<HX-Current-URL> header.
=head3 htmx->req->history_restore_request
C<true> if the request is for history restoration after a miss in the local history cache.
Based on C<HX-History-Restore-Request> header.
=head3 htmx->req->prompt
The user response to an C<hx-prompt>.
Based on C<HX-Prompt> header.
=head3 htmx->req->request
Always C<true>.
Based on C<HX-Request> header.
=head3 htmx->req->target
The C<id> of the target element if it exists.
Based on C<HX-Target> header.
=head3 htmx->req->trigger_name
The C<name> of the triggered element if it exists.
Based on C<HX-Trigger-Name> header.
=head3 htmx->req->trigger
The C<id> of the triggered element if it exists.
Based on C<HX-Trigger> header.
=head3 htmx->req->to_hash
Turn htmx request into a hash reference.
=head2 RESPONSE HELPERS
=head3 htmx->res->location
Allows you to do a client-side redirect that does not do a full page reload.
Based on C<HX-Location> header.
=head3 htmx->res->push_url
Pushes a new url into the history stack.
Based on C<HX-Push-Url> header.
=head3 htmx->res->redirect
Can be used to do a client-side redirect to a new location.
Based on C<HX-Redirect> header.
=head3 htmx->res->refresh
Full refresh of the page.
Based on C<HX-Refresh> header.
=head3 htmx->res->replace_url
Replaces the current URL in the location bar.
Based on C<HX-Replace-Url> header.
=head3 htmx->res->reswap
Allows you to specify how the response will be swapped.
The possible values of this attribute are:
=over
=item C<innerHTML> The default, replace the inner html of the target element
=item C<outerHTML> Replace the entire target element with the response
=item C<beforebegin> Insert the response before the target element
=item C<afterbegin> Insert the response before the first child of the target element
=item C<beforeend> Insert the response after the last child of the target element
=item C<afterend> Insert the response after the target element
=item C<delete> Deletes the target element regardless of the response
=item C<none> Does not append content from response (out of band items will still be processed).
=back
Based on C<HX-Reswap> header.
=head3 htmx->res->reselect
A CSS selector that allows you to choose which part of the response is used to be swapped in. Overrides an existing C<hx-select> on the triggering element
Based on C<HX-Reselect> header.
=head3 htmx->res->retarget
A CSS selector that updates the target of the content update to a different element on the page.
Based on C<HX-Retarget> header.
=head3 htmx->res->trigger
Allows you to trigger client side events, see L<https://htmx.org/headers/hx-trigger> for more info.
To trigger a single event with no additional details you can simply send the event name like so:
if ($c->is_htmx_request) {
$c->htmx->res->trigger('myEvent');
}
If you want to pass details along with the event, you can use HASH for the value of the trigger:
if ($c->is_htmx_request) {
$c->htmx->res->trigger( showMessage => 'Here Is A Message' );
}
if ($c->is_htmx_request) {
$c->htmx->res->trigger(
showMessage => {
level => 'info',
message => 'Here Is A Message'
}
);
}
If you wish to invoke multiple events, you can simply add additional keys to the HASH:
if ($c->is_htmx_request) {
$c->htmx->res->trigger(
event1 => 'A message',
event2 => 'Another message'
);
}
You may also trigger multiple events with no additional details by sending event
names in ARRAY:
if ($c->is_htmx_request) {
$c->htmx->res->trigger(['event1', 'event2']);
}
Based on C<HX-Trigger> header.
=head3 htmx->res->trigger_after_settle
Allows you to trigger client side events, see L<https://htmx.org/headers/hx-trigger> for more info.
Based on C<HX-Trigger-After-Settle> header.
=head3 htmx->res->trigger_after_swap
Allows you to trigger client side events, see L<https://htmx.org/headers/hx-trigger> for more info.
Based on C<HX-Trigger-After-Swap> header.
=head1 METHODS
L<Mojolicious::Plugin::HTMX> inherits all methods from
L<Mojolicious::Plugin> and implements the following new ones.
=head2 register
$plugin->register(Mojolicious->new);
Register plugin in L<Mojolicious> application.
=head1 SEE ALSO
L<Mojolicious>, L<Mojolicious::Guides>, L<https://mojolicious.org>.
=head1 SUPPORT
=head2 Bugs / Feature Requests
Please report any bugs or feature requests through the issue tracker
at L<https://github.com/giterlizzi/perl-Mojolicious-Plugin-HTMX/issues>.
You will be notified automatically of any progress on your issue.
=head2 Source Code
This is open source software. The code repository is available for
public review and contribution under the terms of the license.
L<https://github.com/giterlizzi/perl-Mojolicious-Plugin-HTMX>
git clone https://github.com/giterlizzi/perl-Mojolicious-Plugin-HTMX.git
=head1 AUTHORS
=over 4
=item * Giuseppe Di Terlizzi <gdt@cpan.org>
=back
=head1 COPYRIGHT AND LICENSE
Copyright (c) 2022-2024, Giuseppe Di Terlizzi
This program is free software, you can redistribute it and/or modify it under
the terms of the Artistic License version 2.0.
=cut