/ #alpinejs #eleventy 

Integrating Alpine.js with Eleventy & YAML files to create Alpine Playground's Collections

Adding collections to Alpine.js Playground, essentially bringing it in line with projects such as awesome-alpine and alpinetoolbox.com.

It came from the fact that I’m curating quite a bit of content for each newsletter and it makes sense that content featured on the newsletter should be accessible in an easy to scan manner.

Here’s the rough mockup of what it should look like, ready for comparison with how it ended up looking on Alpine.js Playground Collections

Mockup of the feature on Excalidraw

While building this new feature, I came across a couple of interesting problems to which I’m documenting the solutions in the post.

There were 2 big parts to the implementation: Eleventy data capture + injection and then implementing with Alpine.js.

Table of Contents

Handling pre-rendering on the Eleventy side

To begin with we need to introduce a select, where we used to inject {{ pkg.description }}, we’ll now need actual interactive content. Seeing as Eleventy renders to static markup, we should default to “examples”, which the content that’s rendered by default on the page.

This means that if for whatever reason the Alpine.js application crashes, the page will still display.

You can read more about how we’re rendering the examples with Eleventy and Nunjucks in How to migrate a bunch of HTML pages (Alpine.js Playground) to Eleventy

A set of ready to use Alpine.js <select
  class="omitted">
  <option value="examples" selected>examples</option>
</select> with TailwindCSS

Re-introducing Alpine.js

We need to add the Alpine.js file from the JSDeliver CDN.

<script
  src="https://cdn.jsdelivr.net/gh/alpinejs/[email protected]/dist/alpine.min.js"
  defer
></script>

We’ll also set up x-data on the root div and the relevant page() function.

<!-- head etc. -->
<body class="flex">
  <div
    x-data="page()"
    class="flex mx-auto flex-col items-center px-8 md:px-32 py-24"
  >
  <!-- rest of template -->
  </div>
  <script>
    function page() {
      return {
        selectedType: "examples"
      }
    }
  </script>
</body>

Sourcing and injecting data from YAML Eleventy data files

YAML file structure & data entry

I’m using YAML because it’s a bit more wieldy than JSON for data entry. An example item looks as follows. It’s a simple list of objects with a title, url and a “by” nested object, which has its own name and URL. Some types of data will have extra fields.

- title: Alpine.js is like Tailwind CSS for JavaScript
  url: https://devmode.fm/episodes/alpine-js-is-like-tailwind-css-for-javascript
  by:
    name: devmode.fm
    url: https://devmode.fm

We add and manually populate the relevant YAML data files:

  • _data/articles.yaml which will be stored under articles, articles & tutorials about Alpine.js
  • _data/builtWith.yaml which will be stored under builtWith, it corresponds to site that are built with Alpine.js
  • _data/components.yaml which will be stored under components, ready to use Alpine.js components
  • _data/demos.yaml which will be stored under demos, cool demos using Alpine.js, there’s a bit of overlap between demos and components
  • _data/podcasts.yaml which will be stored under podcasts, podcast episodes about Alpine.js
  • _data/tools.yaml which will be stored under tools, tools for Alpine.js, eg. alpinejs-devtools, the Alpine.js Discord
  • _data/videos.yaml which will be stored under videos, Alpine.js videos

Setting up Eleventy-YAML integration

By default Eleventy doesn’t support YAML data files.

In order for Eleventy to read YAML data files, we need to follow the documentation. We install js-yaml and use addDataExtension in the .eleventy.js configuration file.

const yaml = require("js-yaml");

module.exports = function(eleventyConfig) {
  eleventyConfig.addDataExtension("yaml", contents => yaml.load(contents));
  // rest of config
}

How to inject Eleventy data into a HTML/Nunjucks template

We need to inject all the data. In order to keep the syntax highlighting from breaking on this file too much, I opted to inject it as a global and in a separate script tag.

With Nunjucks, we can use {{ data | dump | safe }} to print JSON out. JSON is a subset of JavaScript, so it works as a regular JavaScript definition.

We need to do some extra work for examples that’s similar to the work in How to migrate a bunch of HTML pages (Alpine.js Playground) to Eleventy. Namely, we need to loop through the collections.all list and

<!-- head etc. -->
<body class="flex">
  <!-- template -->
  <script>
    // dump a bunch of data on the page
    window.__collections = {
      examples: [
        {% for page in collections.all %}
        { url: {{ page.url | dump | safe }}, title: {{ page.data.title | dump | safe }} },
        {% endfor %}
      ],
      components: {{ components | dump | safe }},
      articles: {{ articles | dump | safe }},
      // tweets: @todo
      demos: {{ demos | dump | safe }},
      sites: {{ builtWith | dump | safe }},
      podcasts: {{ podcasts | dump | safe }},
      tools: {{ tools | dump | safe }},
      videos: {{ videos | dump | safe }}
    };
  </script>
  <!-- rest of body, including scripts -->
</body>

In our Alpine.js component, we can now read from this global. We’ll store the list as a flat list of “options” (for our select) and as a “collections” property.

<!-- head etc. -->
<body class="flex">
  <!-- rest of template & 11ty variable injection -->
  <script>
    function page() {
      return {
        // other properties
        options: Object.keys(window.__collections),
        collections: window.__collections,
      }
    }
  </script>
</body>

Implementing select toggling using Alpine.js

Since we’ve got the available options available as the options property, we can use Alpine.js’ x-for to render out the relevant option elements inside the select. Each option element has its value and x-text bound to the name, eg. “tools” will have value “tools” and its text will be “tools”.

We’ll also 2-way bind to selectedType with x-model on the select.

<select
  class="omitted"
  x-model="selectedType"
>
  <template x-for="option in options" :key="option">
    <option :value="option" x-text="option"></option>
  </template>
  <option value="examples" selected>examples</option>
</select>

This renders as follows, with an extra “examples” option. That’s because Alpine.js has no way of knowing that it needs to wipe out the pre-rendered examples “option” element.

"examples" option rendering twice in the "select" instead of once

We’ll wait until the next section to get rid of pre-rendered elements, for now we’ll handle rendering the correct list of links based on the selectedType.

The current template for the list is pre-rendered statically and is as follows.

<ul class="omitted">
  {% for page in collections.all %}
  <li class="omitted">
    <a
      class="omitted"
      href="{{ page.url }}"
      >{{ page.data.title }}</a
    >
  </li>
  {% endfor %}
</ul>

We’ve not got collections available, which means we can add client-side rendering with Alpine.js to the list. We use the selectedType to get the right collection to loop through using x-for and item in collections[selectedType]. Each of the list items, gets a link (a) with the href bound to the URL of the item and the text of the link set to the title property. We also conditionally render the by property using x-if, if it exists, we’ll render “by name” where name is a link whose text is bound to by.name (if set) or by.url and href is bound to by.url.

<ul class="omitted">
  {% for page in collections.all %}
  <li class="omitted">
    <a
      class="omitted"
      href="{{ page.url }}"
      >{{ page.data.title }}</a
    >
  </li>
  {% endfor %}
  <template x-for="item in collections[selectedType]">
    <li class="list-disc w-full">
      <a
        class="omitted"
        :href="item.url"
        x-text="item.title"
      ></a>
      <template x-if="item.by && item.by.url">
        <span>
          by
          <a
            class="omitted"
            :href="item.by.url"
            x-text="item.by.name || item.by.url"
          ></a>
        </span>
      </template>
    </li>
  </template>
</ul>

As we can see in the following image, much like for the option-s, the list gets rendered twice, once by Eleventy (at build time) and once by Alpine.js (at runtime on the client). We’ll now tackle this issue of clearing pre-rendered elements on Alpine.js initialisation.

Items being rendered twice due to Eleventy and Alpine.js rendering the same content

Cleaning up pre-rendered elements on Alpine.js initialisation

For elements that just need to dissappear when Alpine.js initialises, we could use x-show="false".

We actually want to remove some of these items.

In order to do this, we’ll add the x-remove attribute to all the list items and the option that need to be deleted on initialisation.

<select
  class="omitted"
  x-model="selectedType"
>
  <template x-for="option in options" :key="option">
    <option :value="option" x-text="option"></option>
  </template>
  <option x-remove value="examples" selected>examples</option>
</select>
<!-- rest of template -->
<ul class="omitted">
  {% for page in collections.all %}
  <li class="omitted" x-remove>
    <a
      class="omitted"
      href="{{ page.url }}"
      >{{ page.data.title }}</a
    >
  </li>
  {% endfor %}
  <!-- Alpine.js list items template -->
</ul>

We’ll can then add the following init() function and set it to run using x-init="init()".

Our init function will select all elements inside the Alpine.js component with the x-remove attribute and call the .remove() function on them.

<!-- head etc -->
<body class="flex">
  <div
    x-data="page()"
    x-init="init()"
    class="flex mx-auto flex-col items-center px-8 md:px-32 py-24"
  >
  <!-- rest of template -->
  </div>
  <script>
    function page() {
      return {
        init() {
          this.$el
            .querySelectorAll("[x-remove]")
            .forEach((el) => el.remove());
        },
        // other properties
      }
    }
  </script>
</body>

The list is not not rendering twice and the “examples” option is only appearing once.

Fixed removal of stale content using x-remove attribute and selection/removal in init()

We’ve now seen how to remove pre-rendered content when Alpine.js boots up. In the next section we’ll look at how to overwrite text content with Alpine.js.

Modifying pre and post “select” text with Alpine.js

“A set of ready to use Alpine.js examples with TailwindCSS by Hugo” works but “A set of ready to use Alpine.js articles with TailwindCSS by Hugo” is inaccurate.

Ideally, the “pre” and “post” selected type text could change based on the selected type.

Eg. for articles: “A set of Alpine.js articles by Hugo” would be enough.

In order to do this we need to make the “ready to use Alpine.js” and “with TailwindCSS” dynamic. Which we can do as follows by wrapping then with <span x-text="pre()"></span> and <span x-text="post()"></span> respectively.

 A set of <span x-text="pre()">ready to use Alpine.js</span>
<select><!-- options template etc --></select>
<span x-text="post()">with TailwindCSS</span> by
<a
  class="omitted"
  href="https://codewithhugo.com/tags/alpinejs"
  >Hugo</a>

Implementing the pre and post functions can be done by using a switch over this.selectedType and returning different strings. We default to pre being “Alpine.js” and post being empty. Which means by default we’ll render “A set of Alpine.js {{ selectedType }} by Hugo”, which should work for most things. The other options are just an exercise in copywriting.

<script>
  function page() {
    return {
      // other component properties
      pre() {
        switch (this.selectedType) {
          case "examples":
            return "ready to use Alpine.js";
          case "sites":
          case "tools":
            return "relevant";
          default:
            return "Alpine.js";
        }
      },
      post() {
        switch (this.selectedType) {
          case "examples":
            return "with TailwindCSS";
          case "components":
            return "ready to copy-paste";
          case "articles":
          case "demos":
            return "for your perusal";
          case "sites":
            return "built with Alpine.js";
          case "podcasts":
            return "🎧 for your commute";
          case "tools":
            return "to supercharge your Alpine.js dev";
          default:
            return "";
        }
      },
    };
  }
  </script>

Which yields the following, bit janky to have different sized content maybe but works as designed.

Demo of changing the selected type and it updating the pre and post text

We’ve now seen how to overwrite pre-rendered/default text in Alpine.js using wrapper HTML tags and x-text.

In the next section we’ll look at how to make the Alpine.js state shareable by reading and writing it to the URL.

2-way binding the URL with Alpine.js application state

We’ve now got a working “select” for the collection type.

What if we want to share that page? For example if someone is asking about some demos and I want them to land on the page with “demos” pre-selected? The answer, is to use the URL.

What we’ll do is sync the selectedType as a type query parameter (in the URL).

Fetching “type” query param from URL

To get the “type” parameter from the URL, we can create a URLSearchParams object from window.location.search and call .get('type') which will get the type query parameter if it exists from the URL’s search section (the part after the ?).

If type is not set, we’ll default to “examples”.

Once we’ve got the type from the URL, we can use it as the initial value for selectedType in the component reactive properties.

In our page function that gives the following.

<script>
function page() {
  const selectedType =
    new URLSearchParams(window.location.search).get("type") || "examples";
  return {
    // same as selectedType: selectedType
    // see "JS object properties shorthand"
    selectedType,
    // other properties
  }
}
</script>

When we go to try this out, we get an issue though. If we manually go to ?type=tools we get the following state in the application. Where examples is selected in the “select” despite items in the tools collection being displayed.

`examples` is selected in the "select" despite items in the `tools` collection being displayed

The way to fix this is to actually set the selectedType as "" initially.

Then during init(), after we remove the items with x-remove and Alpine.js finishes rendering, we can set it. The code would be as follows.

<script>
  function page() {
    // populate selectedType
    return {
      selectedType: "",
      init() {
        // remove elements that have `x-remove`
        this.$nextTick(() => {
          this.selectedType = selectedType;
        });
      },
      // other properties
    };
  }
</script>

We’ve now seen how we can drive the application’s initial state through the URL. However, the application’s URL doesn’t change when the selected type changes.

Next we’ll see how we can make sure any changes in selected type are synced to the URL.

Using $watch to set the query param on selectedType change

In order to sync state back the the URL, we’ll use $watch and history.pushState from the History API.

What we want to do is that whenever selectedType changes, we should do history.pushState with the type query parameter set to the new value of selectedType.

To achieve this, we’ll use $watch on selectedType. The $watch callback will check if the value is different from the previous one (by comparing against a cached version). If the value of selectedType is different, we’ll get the current URL (as a URL object), set the type query parameter to the new value (using new URL().searchParams.set) and call history.pushState with it (since pushState takes 3 parameters, in order, the state, the title and the new URL, we’ll pass null, document.title and the new url). We’ll also take care to update the “previous value” cache that we’re keeping.

This is implemented as follows.

<script>
  function page() {
    // populate selectedType
    return {
      // other properties
      init() {
        // other init code
        let prevType = selectedType;
        this.$watch("selectedType", (value) => {
          // only sync to URL on change...
          if (prevType !== value) {
            const url = new URL(window.location.href);
            url.searchParams.set("type", value);
            history.pushState(null, document.title, url.toString());
            prevType = value;
          }
        });
      },
      // other properties
    }
  }
</script>

We’ve now seen how sync any changes in selected type to the URL.

Implementing the new Alpine.js Weekly Collections with Alpine.js, Eleventy and sprinkles of YAML was great.

If you’re interested in Alpine.js, Subscribe to Alpine.js Weekly. A free, once–weekly email roundup of Alpine.js news and articles

unsplash-logoIñaki del Olmo

Author

Hugo Di Francesco

Co-author of "Professional JavaScript" with Packt. He runs the Code with Hugo website helping over 100,000 developers every month and holds an MEng in Mathematical Computation from University College London (UCL). He has used JavaScript extensively to create scalable and performant platforms at companies such as Canon and Elsevier.

Interested in Alpine.js?

Subscribe to Alpine.js Weekly A free, once–weekly email roundup of Alpine.js news and articles