Nov 10, 2024

Oops, I built a headless frontend with 11ty

My journey with 11ty’s global data went something like this:

  1. “This is where my little text strings go”
  2. “Neat, I can use javascript to set dynamic values”
  3. “Literally any service with an API is my CMS now”

That last phase is key to understanding how our recent help site migration went off-script. We’d been using Intercom for support, but at the end of the day we just weren’t satisfied with the experience for customers, or ourselves. We ended up switching to a combination of HelpScout for a help desk, and Loops for marketing and onboarding sequences (now email-based).

I was all set to do some quick styling on the Helpscout’s hosted help pages, and started digging into their docs when I saw a line of text that instantly set the project off course:

The full Help Scout Docs API documentation can be found here: https://developer.helpscout.com/docs-api/.

“Literally any service with an API is my CMS now”

Memory is hazy about what happened next; all I know is that several days later I looked up my keyboard and I’d designed/built a headless front-end for our docs site on our 11ty-powered website. Here’s how it works.

Authentication

You need an API key to get data from your Docs site; I’m using a standard-issue dotenv setup. The steps in brief:

  1. Install dotenv in your 11ty project.
  2. Create a .env file and add it to your .gitignore file.
  3. Add your API key to the .env file and give it a name, like HELPSCOUT_DOCS=your-key-here
  4. import dotenv at the top of your eleventy config file, like so: import 'dotenv/config';
  5. Also add your key to Cloudflare Pages / Netlify / whatever.

Talkin’ to APIs

The authenticate-and-fetch part is handled by a small helper called fetchHelpScoutData.js:

import EleventyFetch from '@11ty/eleventy-fetch';
import { environment } from '../_data/env.js';
const apiKey = process.env.HELPSCOUT_DOCS;

export default async function fetchHelpScoutData(endpoint, apiKey) {
  const url = `https://docsapi.helpscout.net/v1/${endpoint}?status=published`;
  const key = apiKey || process.env.HELPSCOUT_DOCS;

  const options = {
    duration: environment === 'development' ? '1d' : '5m',
    type: 'json',
    fetchOptions: {
      headers: {
        'Authorization': `Basic ${Buffer.from(key + ':X').toString('base64')}`,
        'Content-Type': 'application/json'
      }
    }
  };

  try {
    return await EleventyFetch(url, options);
  } catch (error) {
    console.error(`Error fetching ${endpoint}:`, error);
    return null;
  }
}

This helper does two important things:

  1. It keeps from having to repeat a bunch of boilerplate for every endpoint we want to grab data from. We have to iterate over multiple endpoints to get the data we need, so a clean abstraction pays dividends here.
  2. It caches the data for a period of time using 11ty fetch so we don’t hit the API every time someone runs a build in local dev.

Building global data

From there, we construct global data files that get used in templates. There are three; one that returns what amounts to a sitemap (nested titles and links for collections, categories, and articles), one that returns categories and the articles within, and one that returns the full content of help articles.

Starting with _data/help-map.js:

import fetchHelpScoutData from '../_utilities/fetchHelpScoutData.js';

export default async function nestedAlt() {
  const map = [];
  const data = await fetchHelpScoutData('collections');
  for (let collection of data.collections.items) {
    const collectionObject = { ...collection, categories: [] };
    const categoriesData = await fetchHelpScoutData(`collections/${collection.id}/categories`);
    for (let category of categoriesData.categories.items) {
      const categoryObject = { ...category, entries: [] };
      const articlesData = await fetchHelpScoutData(`categories/${category.id}/articles`);
      categoryObject.entries = articlesData.articles;      
      if (categoryObject.slug != 'uncategorized') {
        collectionObject.categories.push(categoryObject);
      }
    }
    map.push(collectionObject);
  }
  return map;
};

This demonstrates the general approach of iterating over multiple endpoints to build up a data structure that’s easy to work with. The end result here is an array of collection objects that include arrays of category objects, which contain arrays of article objects.

Here’s _data/help-categories.js:

import fetchHelpScoutData from '../_utilities/fetchHelpScoutData.js';

export default async function collections() {
  const categories = [];
  const data = await fetchHelpScoutData('collections');
  for (let collection of data.collections.items) {
    const categoriesData = await fetchHelpScoutData(`collections/${collection.id}/categories`);
    for (let category of categoriesData.categories.items) {
      const categoryObject = { ...category, entries: [] };
      const articlesData = await fetchHelpScoutData(`categories/${category.id}/articles`);
      categoryObject.entries = articlesData.articles;
      if (categoryObject.slug != 'uncategorized') {
        categories.push(categoryObject);
      }
    }
  }  
  return categories;
};

And finally our articles, via _data/help-articles.js:

import fetchHelpScoutData from '../_utilities/fetchHelpScoutData.js';

export default async function articles() {
  const articles = [];
  const data = await fetchHelpScoutData('collections');
  for (let collection of data.collections.items) {
    const categoriesData = await fetchHelpScoutData(`collections/${collection.id}/categories`);
    for (let category of categoriesData.categories.items) {
      const articlesData = await fetchHelpScoutData(`categories/${category.id}/articles`);
      for (let article of articlesData.articles.items) {
        const articleData = await fetchHelpScoutData(`articles/${article.id}`);
        if (articleData.article.related) {
          const relatedArticles = [];
          for (let relatedArticle of articleData.article.related) {
            const relatedArticleData = await fetchHelpScoutData(`articles/${relatedArticle}`);
            const { name, slug, number } = relatedArticleData.article;
            relatedArticles.push({ name, slug, number });
          }
          articleData.article.relatedArticles = relatedArticles;
        }
        articles.push(articleData.article);
      }
    }
  }
  return articles;
};

This one gets more complex in order to pull in the related entry data for each article, which we’re using for “next page” links within guides.

Building views

From here on out, the process is no different than if you were working with local global data file. All of the pages are handled by one layout ( _layouts/docs.liquid), a partial for the sidebar (_includes/docs/sidebar.liquid), and three templates; docs/index.liquid for the landing page, docs/category.liquid for the category pages, and docs/article.liquid for the individual article pages.

Let’s start with the sidebar:

<nav class="docs-sidebar">
  {% for collection in help-map %}
    <small-details>
      <details open>
        <summary>{{ collection.name }}</summary>
        {% for category in collection.categories %}
          {% if id %}
            {% assign matchingPages = category.entries.items | where: "id", id %}
          {% endif %}
          <details {% if matchingPages.size > 0 %}open{% endif %} name="categories">
            <summary>{{ category.name }}</summary>
            <ul>
              {% for entry in category.entries.items %}
                <li>
                  <a {% if entry.id == id %}aria-current="page"{% endif %} {% if entry.name.size > 30 %}title="{{ entry.name }}"{% endif %} href="/docs/article/{{ entry.number }}-{{ entry.slug }}/">{{ entry.name }}</a>
                </li>              
              {% endfor %}
            </ul>
          </details>
        {% endfor %}
        </details>
    </small-details>
  {% endfor %}
</nav>

The prep work on data structure pays off here, where I can easily iterate over the entries within help-map. A couple of points worth noting:

  • This uses an exclusive accordion approach, which is now trivially simple in CSS. Give > 1 details element the same name value and you get an exclusive accordion.
  • I wanted a given category details element to be open when you’re viewing an article and have the article selected. So I’m checking for a matching ID and using it to selectively set the open attribute on the matching category details and aria-current="page" on the article itself.

The landing page for the docs displays the two collections and their categories:


{% for collection in help-map %}
  <h2 class="span-12 weight-200 mb-md">{{ collection.name }}</h2>
  <div class="docs-grid start-1 span-10">
    {% for category in collection.categories %}
        <div class="docs-card shadow-lg">
          <a class="docs-card__hero" href="/docs/category/{{ category.number }}-{{ category.slug }}/">
            <div class="docs-card__content">
              <h3 class="docs-card__title">{{ category.name }}</h3>
              {% if category.description %}
                <div  class="docs-card__description">
                  {{ category.description }}
                </div>
              {% endif %}
            </div>
          </a>
        </div>
    {% endfor %}
  </div>
{% endfor %}

The category and article templates are where things get a little more interesting because they are used to generate pages from our HelpScout data. That’s where 11ty’s pagination feature comes into play. Here’s the template for category pages:

---
layout: docs.html
pagination:
  data: help-categories
  size: 1
  alias: category
permalink: "docs/category/{{ category.number }}-{{ category.slug }}/"
eleventyComputed:
  title: "{{ category.name }}{{ site.title }}"
  description: "{{ category.description | strip_html | truncate: 160 }}"
---

{% render 'docs/sidebar.html', site: site, help-map: help-map %}

<section class="docs-primary">
  <article class="docs-column">
    <nav class="docs-crumbs">
      <a href="/docs/">docs</a></a>
    </nav>
    <h1 class="mt-0 mb-md leading-flush size-xxxl">{{ category.name }}</h1>
    <ul class="docs-category">
      {% for article in category.entries.items %}
        {% if article.name %}
          <li>
            <a href="/docs/article/{{ article.number }}-{{ article.slug }}/">{{ article.name }} <span class="arrow"></span></a>
          </li>
        {% endif %}
      {% endfor %}
    </ul>
  </article>
</section>

What that frontmatter pagination block translates to is “generate one page for every entry in help-categories” and give help-categories an alias of ‘categories’ that I can use in my template.

The article template is more of the same:

---
layout: docs.html
pagination:
  data: help-articles
  size: 1
  alias: article
permalink: "docs/article/{{ article.number }}-{{ article.slug }}/"
eleventyComputed:
  title: "{{ article.name }}{{ site.title }}"
  description: "{{ article.text | strip_html | truncate: 160 }}"
---
{% assign category = help-categories | where: "id", article.categories[0] | first %}

{% render 'docs/sidebar.html', site: site, help-map: help-map, id: article.id %}

<section class="docs-primary">
  <article class="docs-content" data-pagefind-body>
    
    <div class="docs-formatted">
      <nav class="docs-crumbs">
        <a href="/docs/">docs</a> / <a href="/docs/category/{{ category.number }}-{{ category.slug }}/">{{ category.name }} /</a>
      </nav>
      <h1 class="mt-0 mb-md leading-flush size-xxxl">{{ article.name }}</h1>
      {{ article.text }}
      {% if article.relatedArticles %}
        {% assign next = article.relatedArticles[0] %}
        <div class="docs-related">
          <p>
            <strong>Next:</strong> <a href="/docs/article/{{ next.number }}-{{ next.slug }}/">{{ next.name }}&nbsp;</a>
          </p>
        </div>
      {% endif %}
    </div>

    <table-of-contents></table-of-contents>
  </article>
  
</section>

One of the nice things about HelpScout is that their docs data is stored as HTML, so we don’t need to do any processing or translation on the content.

That table-of-contents tag is a custom element that includes clickable links of all of the major headings within an article, that auto-updates the active heading as you scroll. The heading ID’s are auto-generated by the new ID attribute plugin in 11ty 3.0. Here’s what the custom element looks like:

class TableOfContents extends HTMLElement {
  connectedCallback() {
    this.render();
    this.observeHeadings();
  }

  render() {
    const headings = Array.from(document.querySelectorAll('h2'))
      .filter(heading => heading.id);

    if (headings.length < 2) {
      this.style.display = 'none';
      return;
    }

    const toc = this.generateTOC(headings);
    
    this.innerHTML = `
      <h3>On this page</h3>
      <div>${toc.outerHTML}</div>
    `;
  }

  generateTOC(headings) {
    const toc = document.createElement('ul');
    const stack = [{ level: 1, element: toc }];

    headings.forEach(heading => {
      const level = parseInt(heading.tagName.charAt(1));
      const listItem = document.createElement('li');
      const link = document.createElement('a');
      link.textContent = heading.textContent;
      link.href = `#${heading.id}`;
      listItem.appendChild(link);

      while (level <= stack[stack.length - 1].level) {
        stack.pop();
      }

      if (level > stack[stack.length - 1].level) {
        const newList = document.createElement('ul');
        stack[stack.length - 1].element.appendChild(newList);
        stack.push({ level, element: newList });
      }

      stack[stack.length - 1].element.appendChild(listItem);
    });

    return toc;
  }

  observeHeadings() {
    const headings = Array.from(document.querySelectorAll('h2'))
      .filter(heading => heading.id);

    let isScrolling = false;
    let clickedLink = null;

    const observer = new IntersectionObserver(
      (entries) => {
        if (!isScrolling && !clickedLink) {
          const visibleHeadings = entries.filter(entry => entry.isIntersecting);
          if (visibleHeadings.length > 0) {
            const firstVisibleHeading = visibleHeadings[0].target;
            this.highlightTOCItem(firstVisibleHeading);
          }
        }
      },
      { rootMargin: '0px 0px 0px 0px' }
    );

    headings.forEach(heading => observer.observe(heading));

    const tocLinks = this.querySelectorAll('a');
    tocLinks.forEach(link => {
      link.addEventListener('click', (event) => {
        event.preventDefault();
        const targetId = link.getAttribute('href').slice(1);
        const targetElement = document.getElementById(targetId);
        if (targetElement) {
          clickedLink = link;
          this.highlightTOCItem(targetElement);
          targetElement.scrollIntoView({ behavior: 'smooth' });
        }
      });
    });

    window.addEventListener('scroll', () => {
      if (clickedLink) {
        isScrolling = true;
        clearTimeout(window.scrollTimeout);
        window.scrollTimeout = setTimeout(() => {
          isScrolling = false;
          clickedLink = null;
        }, 100);
      }
    }, { passive: true });
  }

  highlightTOCItem(heading) {
    const links = this.querySelectorAll('a');
    links.forEach(link => link.classList.remove('active'));

    const headingId = heading.id;
    const correspondingLink = this.querySelector(`a[href="#${headingId}"]`);
    if (correspondingLink) {
      correspondingLink.classList.add('active');
    }
  }
}

customElements.define('table-of-contents', TableOfContents);

There’s a lot going on here, but generally we are:

  • Finding all the h2 elements on the page and using them to build our TOC.
  • Observing which heading is within the viewport and updating the active TOC element as you scroll.
  • Updating the active TOC element when you click on it directly.

At this point, we’ve got all of our pages in place, and some solid patterns for fast and fluid navigation. Just one thing missing; search.

Adding search with Pagefind

In my experience, adding search to a static site is painful, clunky, or both. Which is why I was delighted to discover Pagefind. You need to do four things; set up your search input, tell Pagefind what to index, include the Pagefind CSS and JS, and trigger indexing as part of your build.

The search input is set up jump menu style, courtesy of a js-free popover:

<dialog popover id="search" class="modal shadow-lg">
  <div id="searchResults"></div>
  <script>
    window.addEventListener('DOMContentLoaded', (event) => {
      new PagefindUI({
        element: "#searchResults",
        showSubResults: false,
        showImages: false,
        resetStyles: false
      });
    });
  </script>
</dialog>

Telling Pagefind what to index is as simple as adding a data attribute to the context you want to index. In this case, I added it to the element that wraps article content:

<article class="docs-content" data-pagefind-body>
  ...
</article>

I set up the PageFind CSS and JS behind a pagefind front matter key so that any page or layout can opt-in as need be.

{% if pagefind %}
  <link href="/pagefind/pagefind-ui.css" rel="stylesheet">
  <script src="/pagefind/pagefind-ui.js" type="module"></script>
{% endif %}

To build the index, I created a new index script in the project’s package.json file, and added it to the existing scripts for staging and production build (for local dev I run the script as needed only):

"scripts": {
  "start": "ELEVENTY_ENV=development eleventy --serve --incremental --quiet",
  "publish:stage": "ELEVENTY_ENV=stage eleventy && npm run index",
  "publish:prod": "ELEVENTY_ENV=prod eleventy && npm run index",
  "index": "npx pagefind --site _public"
}

That’s all there is to it. Well done Pagefind.

Wrapping up

For a couple days of work to design and build, the results were well worth it. We get all the benefits of keeping help content in HelpScout (CMS for easy management, being able to include help page content in responses to customers, etc), and all of the benefits from a completely bespoke frontend (better experience, tighter brand integration, better SEO from not having help content on a subdomain). The project didn’t go as planned, but I wouldn’t change a thing.

Read more notes