ganch.dev

Enhance Hugo sites with Unpoly

· Georgi Ganchev

hugo-unpony-logo

I’m quite new to Hugo, my blog site also happens to be my first Hugo website, so I decided to play around and learn how to build static websites with it.

Being mainly a Ruby on Rails developer has its perks because we often find new tools to get things done elegantly. A few years ago, I stumbled upon a gem, and this time, surprisingly, it wasn’t a Ruby gem! It was the JavaScript library called Unpoly, this little but powerful library makes interacting with the DOM and HTML pages a lot more fun. If my memory serves me right Unpoly was designed to be an alternative to another JS library from the Rails ecosystem called Tubrolinks. It not only offers more features than Turbolinks but also competes well with its successor, Turbo.

What’s unique and common in these libraries is that they all alter the default navigation of web pages. Unpoly, for example, achieves this by enabling partial page updates through AJAX requests, effectively allowing developers to refresh specific sections of a page without the need for full page reloads. This approach results in a more dynamic and responsive user experience and also reduces the server and network load, contributing to faster and smoother interactions within the web application.

Let’s see what we can do with it in the world of Hugo!

As we already know, Hugo is just a tool for generating HTML pages from templates, that’s all. Anchor links in Hugo sites behave like normal website links - we send a GET request to a page at /post/enhance-hugo-unpoly and the server responds back with the full HTML page. We can easily fit Unpoly JS in this flow here to fetch and inject content dynamically, eliminating the need for full page reloads. This will allow us to create better interactive features on static sites, such as dynamic forms or live search.

In this tutorial, we’ll enhance a Hugo website by adding two key features with the assistance of Unpoly. These are:

  • Fragmented page loads
  • Live search

The end result can be seen here: https://ganchdev.github.io/hugo-unpoly-tailwind/ Repository source: https://github.com/ganchdev/hugo-unpoly-tailwind


Install Unpoly JS

We first need to install Unpoly to our hugo website, this is the original installation instructions page. Many installation methods are supported depending on preference but for the sake of simplicity we’ll just pull the two minified (js and css) library files - they’re relatively tiny!

Then add them to the head.html partial:

  <!-- Styles -->
  <link rel="preload stylehseet" as="style" href="{{ .Site.BaseURL }}css/unpoly.min.css">
  <!---->

  <!-- Scripts -->
  <script src="{{ .Site.BaseURL }}js/unpoly.min.js"></script>
  <!---->

Unpoly is now all set up in our Hugo website. To double-check, just go to the website in development, open the browser’s developer tools, and look out for this:

dev-tools

Fragmented page loads

Let’s start with the first feature – fragmented page loads. We can achieve this using custom HTML attributes provided by Unpoly’s API — up-follow , up-preload and up-instant

For example, we can modify the anchor tags in the ./layouts/_default/list.html template for listing posts.

<div id="all-posts">
  {{ $paginator := .Paginate $pages }}
  {{ range $index, $page := $paginator.Pages }}
    <section id="{{ .File.BaseFileName }}" data-title="{{ .Title }}">
      <a up-follow up-preload up-instant href="{{ .Permalink }}">
        <h2>{{ .Title }}</h2>
        <p>{{ .Description }}</p>
        <time>{{ .Date | time.Format ":date_medium" }}</time>
      </a>
    </section>
  {{ end }}
</div>

up-follow

To enable fragmented loads, we’ll use the up-follow attribute, which tells Unpoly to follow the link and update a specified HTML element with the content fetched from that link. In our case, we aim to update the primary element of our website layout, the default primary element is the <main> element. However, we can customize it to target any other HTML element as needed.

up-main

To change the primary element, simply add up-main to one of the outer tags within your baseof.html template. Setting it will make Unpoly update this specific element.

up-prelaod

This option enables our links to preload the target page if hovered, creating an instant feel when clicked later. HTTP requests are made in the background and are kept cached, waiting for users to interact with the links.

up-instant

Follow the page links when mousedown mouse event is triggered instead of click

In a statically generated Hugo site with no backend, getting the search feature to work in paginated lists takes a bit of creativity. Here’s the idea: we create a separate HTML page that renders all the posts, like a document structure for Unpoly to grab those post fragments and slot them right into the current page.

The flow in steps:

  1. We start by fetching a page, such as /all , which lists all available posts.
  2. We then search for matches within the post titles based on the provided search query.
  3. When we find a match, we replace the current page’s content with the matched fragment from the /all page.

Create “All Posts” page

We can do this by creating a new file at ./layouts/section/all.html , copying the structure from our ./layouts/_default/list.html because we have to keep the same look as we’ll be replacing fragments with it. Here’s a snippet of what it might look like:

<div id="all-posts">
  {{ range where .Site.Pages "Type" "post" }}
    {{ if ne .File.BaseFileName "_index" }}
      <section id="{{ .File.BaseFileName }}" data-title="{{ .Title }}">
        <a up-follow up-preload up-instant href="{{ .Permalink }}">
          <h2 >{{ .Title }}</h2>
          <p>{{ .Description }}</p>
          <time>{{ .Date | time.Format ":date_medium" }}</time>
        </a>
      </section>
    {{ end }}
  {{ end }}
</div>

We iterate through all the pages on the website (.Site.Pages ) that have a “Type” of “post” and, for each page that is not an index page (identified by its filename not being “_index”), we create a <section> element and populate it with the post’s title, description, and date. These posts are wrapped in <a> tags and have all Unpoly tags needed for fragmented page loads.

Add page fetching functionality

We’ll now need to add an input field to trigger the searching:

<input
  name="search-posts"
  id="search"
  up-watch="searchPosts(value)"
  up-watch-delay="300"
  type="text"
  placeholder="Search something.." />

up-watch

This tells Unpoly to watch our input field and run a callback when its value changes. The up-watch-delay adds debouncing to the callbacks, i.e. wait 300 milliseconds every time the value changes before running the searchPost(value) function.

Finally, let’s add the script

We can add this script to a partial in ./layouts/partials/script.html and then include it in the baseof.html template.

<script>
// Search for posts
async function searchPosts(value) {
  // If no search value provided, return to the base URL
  if (!value) { return await up.navigate({ url: window.baseUrl }) }

  // Function to handle when a fragment is loaded;
  // here we remove the pagination when fragments are
  // inserted on the page
  function fragmentLoaded() {
    const pagination = document.querySelector(".posts-nav");
    if (pagination) {
      pagination.remove();
    }
  }

  // Function to handle when a fragment is rendered
  function fragmentRendered(renderResult) {
    // Get the currently displayed sections on the page
    const currentSections = document.querySelectorAll("section")
    // Get the sections from the newly rendered fragment
    const renderedSections = renderResult.fragment.querySelectorAll("section");

    // Find sections with matching titles and get their IDs
    const matchingSectionsIds = Array.from(renderedSections)
      .filter((section) => {
        const dataTitle = section.getAttribute('data-title');
        return dataTitle.toLowerCase().includes(value.toLowerCase());
      })
      .map((section) => section.id);

    // Remove sections that don't match the search criteria
    currentSections.forEach((section) => {
      if (!matchingSectionsIds.includes(section.id)) {
        section.remove();
      }
    });
  }

  // Fetch the url and render the search results
  // to the target id `all-posts` using Unpoly
  await up.render({
    target: "#all-posts",
    url: `${window.baseUrl}all`,
    onLoaded: fragmentLoaded,
    onRendered: fragmentRendered
  });
}
</script>

We’ve used a few more methods from the API here – up-navigate and up-render. Make sure you check out what they do and what their settings are; it’s all pretty intuitive once you’ve got the hang of how page fragments work.

The global window.baseUrl as the example here is set in one of our templates because the value comes from Hugo itself.

<!-- Set JS Base URL -->
<script>
  window.baseUrl = "{{ .Site.BaseURL }}";
</script>
<!---->

We’re done

That’s about it! It’s not too challenging to add a dynamic touch to static websites built with Hugo; it all comes down to HTML, JS, and CSS, after all. While Unpoly offers many more interesting features like dynamic forms and form validations, implementing them would require a working backend. I’ve chosen not to add any of these in here, as it diverges from the core purpose of Hugo.

Bye!