Skip to content

Supercharging your static site with htmx and soupault

We will use Soupault as a static website framework that we will use to manipulate the HTML of pages generated by Hugo. Although Soupault itself can be used as a static site generator, this is not how we will use it here.

The goal of this post is to enhance the Hugo website with htmx, and more specifically with hx-boost and htmx preload. hx-boost will load pages with AJAX requests and update the body and title of the page with data retrieved. htmx preload lets you preload a link to make your site feel faster. We will use the default of preloading the link on the mousedown event, ensuring we will not send out useless requests as the mousedown event indicates the user pressed the mouse button to trigger a click on the link.

Important note: using both at the same time doesn’t seem to be beneficial: in my (simple and basic) tests, preload would indeed issue the request at mousedown, but htmx-boost would still send an xhr request without using the cache. In my case it was htmx-boost that made the website feel faster, which is the one I ended up enabling on this site.

Why soupault?

To use the htmx features we want, there are two steps to take:

  1. load the javascript files to make the htmx features available on the page. This is done by adding a <script> element in the page’s <head>
  2. add an attribute to the elements we want to boost or preload

As we see, these are two HTML manipulations, tasks at which soupault really excels.

Setting up soupault

Once you installed soupault, we generate its config file at the root of our hugo website’s directory (where Hugo’s config.toml is located) with

soupault --init

This generates a soupault.toml file.

We edit this file and apply 2 changes. The first change is to change the site_dir value to the directory where Hugo generates its static html, in my case public. By default soupault generates its output in build, as configure with the setting build_dir which we leave as iso.

The second change is to set generator_mode = false. This indicates to soupault it is just manipulating existing HTML pages, and not handling templating and other static site generation tasks.

Using soupault

The operations applied to the HTML handled by soupault are done by widgets. We apply widgets to our HTML by instanciating these widgets in our soupault configuration. We instanciate widgets by adding widgets subsections in the soupault configuration. That is, we add sections like [widgets.my_chosen_name], where my_chose_name is an arbitraty identifier we give to that specific widget instanciation. Keys under that [widgets.my_chosen_name] will configure the widget instanciation, including which widget is actually instanciated..

Soupault brings built-in widgets, including for HTML manipulation.

As an example scenario, let’s say you inherit a statically generated site having a banner, and you don’t have the source of the site, but you need to remove that banner from all pages. That’s very easy with the delete_element widget. You can delete the banner (let’s say it is the div with id banner) with this config in soupault.toml:

# Remember the 'delete_banner' name is arbitrary
[widgets.delete_banner]
  widget = "delete_element" # -> the widget we instanciate
  selector = "#banner"      # -> a configuration element of
                            #    the widget: the CSS selector
                            #    identifying elements to be deleted

Each widget is applied on every page found in the site_dir, letting us apply transformations to every page of our static site by running soupault --build. The results can be found under build/.

Soupault plugins

Soupault might provide an extensive number of widgets, it can’t possibly cover every need. That’s why it is possible to develop widgets using the Lua language. Note however the Lua version used is 2.5, so not the very latest one. The reason is that Soupault embeds a Lua implementation developed in the same language as itself. This didn’t appear to be problematic in our experience.

The plugins are lua scripts to be placed in the directory specified with the Soupault configuration directive plugin_dirs, being by default plugins/. In these lua scripts, you have access to an extensive library of functions to manipulate the HTML of the page in addition to others like Regex, strings manipulation, table functions, etc

htmx boost

Intro

The htmx boost feature is built in htmx, and is triggered by setting the attribute hx-boost=true on the element you want to boost. The boost is inherited by the element’s children and hence the boost is often set on the <body>. That is also what we’ll do.

We will develop a plugin that will

  1. Look at the HTML of the page and determine if there are boosted elements in there. I.e. check if there are elements with hx-boost="true"
  2. Boost elements in the page corresponding to the CSS selector in the widget instanciation’s selector configuration. I.e. add the attribute hx-boost="true" to elements corresponding to the selector
  3. If the previous points determine that the page has boosted elements, add the htmx script in the page’s <head>. This means that if the page the script is working on does not have any boosted elements, htmx will not be loaded and there’s no price paid.

The htmx boost widget will be instanciated like this in our soupault.toml:

[widgets.htmx-boost]
  widget = "htmx-boost"
  # no selector applies the boost to <body>
  #selector = "a.nav-link"

Having the widget configuration will help us understand the plugin’s code, at which we can now take a look.

Plugin code

We explore the code in chunks below, but you can find the whole code at https://gitlab.com/rbauduin/soupault-plugins/-/blob/master/htmx-boost.lua We put the code in the file plugins/htmx-boost.lua. With plugins auto-discovery enabled (which is the default), this will automatically define a widget similarly named htmx-boost. This is indeed the name of the widget instanciate under [widgets.htmx-boost].

We start by extracting the selector identifying which elements should get the hx-boost="true" attribute:

-- determine which elements will be boosted, according to the selector config.
selector = config["selector"]
if not selector then
  selector = "body"
  Log.warning("htmx boost selector is not configured, using default " .. selector)
end

Based on this selector, we can extract the corresponding elements from the page, and determine the number of corresponding elements:

-- Elements to be boosted according to selector
to_boost = HTML.select(page, selector)
to_boost_count = size(to_boost)

The HTML we are working on might already have to boosted elements, which we also extract and count:

-- Elements already boosted in HTML we work on
boosted = HTML.select(page, "*[hx-boost=\"true\"]")
boosted_count = size(boosted)

Similarly, the HTML we are working on might already load htmx. So we write a function has_html() returning a boolean indicating if htmx is already loaded by the document:

-- function to determine if the page already has htmx loaded
function has_htmx()
  local htmx = HTML.select(page, "script[src*=\"htmx.org\"]")
  return  size(htmx)>0
end

Finally, we write a function to insert the htmx script element in the page’s <head> if it is not yet present. By default the plugin loads the htmx script from a CDN at https://unpkg.com/htmx.org@1.9.8 with integrity checking, but it can be vendored. In that case, the whole <script> tag has to be passed in the config htmx_script_tag.

-- function to add the htmx script element to the page's head
function load_htmx ()
  if not (has_htmx()) then
    head = HTML.select_one(page, "head")
    if head then
      -- build script tag
      htmx_script_tag = config["htmx_script_tag"]
      if not htmx_script_tag then
        Log.warning("htmx is loaded from CDN, it is preferred to be vendored and served locally using option htmx_script_tag using option htmx_script_tag..")
        htmx_script= HTML.parse([[<script src="https://unpkg.com/htmx.org@1.9.8" integrity="sha384-EAzY246d6BpbWR7sQ8+WEm40J8c3dHFsqC58IgPlh4kMbRRI6P6WA+LA/qGAyAu8" crossorigin="anonymous"></script>]])
      else
        htmx_script= HTML.parse(htmx_script_tag)
      end
      Log.info("Inserting htmx script node in head")
      HTML.append_child(head, htmx_script)
    else
      Log.error("no head found in document")
    end
  end
end

Finally, we apply the required changes to the page:

-- Load htmx if needed
if to_boost_count > 0  or boosted_count > 0 then
  load_htmx()
end

-- Add hx-boost to elements matching the selector
local i = 1
while i <= to_boost_count do
  HTML.set_attribute(to_boost[i],"hx-boost","true")
  i = i+1
end

Using the plugin

As a reminder, here is the configuration for the htmx boost widget instanciation:

[widgets.htmx-boost]
  widget = "htmx-boost"
  # no selector applies the boost to <body>
  #selector = "a.nav-link"

Calling soupault --build you should see these lines in the log, indicating the htmx-boost widget has been applied:

[INFO] Processing widget htmx-boost on page public/page/3/index.html
[WARNING] htmx boost selector is not configured, using default body

You can then edit one of the html pages generated under build/ and check that the body has the attribute hx-boost="true". You can also serve the pages with an http server. Go in the build/ directory and start an http server, for example with python: python -m http.server and open the site in your browser (eg http://localhost:8080).

htmx preload

The htmx preload plugin is very similar to the htmx boost plugin, and if you understand the code displayed until now, you should understand the new code without problem. The main difference is that the preload feature is not built in, but brought by an htmx plugin. This plugin is loaded by adding a <script> element to the <head>, similarly to what is done to load htmx.

This widget takes as config:

  • selector: a CSS selector identifying the elements to which we need to add the preload attribute. The default value is a[href^='/'], meaning only absolute links to the same domain name are preloaded.
  • preload_event: the event on which the preload is triggered. By default it is mousedown.

Contrary to the htmx boost widget, the htmx preload widget does not try to determine if there are any HTML elements that already have the preload attribute. Based on the htmx boost code, it should be easy to add if you need it.

You can find the plugin code at https://gitlab.com/rbauduin/soupault-plugins/-/blob/master/htmx-preload.lua.