My Little Corner of the Net

Simple Image Galleries With Eleventy

As I continue converting my largest non-work site from Jekyll to Eleventy, I keep coming across things that I did in Jekyll that no longer work in Eleventy.  One of these is image galleries.

Jekyll and Eleventy have a fundamentally different approach to how they handle files.  Jekyll splits all of the files in the project folder into two types, based on whether or not they contain front matter.  Files with front matter are transformed and the result is saved to the site folder.  Files without front matter (which includes all images, PDFs, JavaScript, CSS, etc.) are simply copied to the site.  The latter, which Jekyll refers to as “static files” are placed into a static_files collection which can be accessed in templates.  I was able to use this collection to make simple photo galleries.

First, I created a gallery.html layout that looked something like this:

{{ content }}

<div class="gallery">
  {% for image in site.static_files %}
    {% if image.path contains page.gallerydir %}
      <div class="gallery-image"><a href="{{ image.path }}" class="gallery-link"><img src="{{ image.path }}"></a></div>
    {% endif %}
  {% endfor %}
</div>

Then, for each gallery page, I’d add a markdown file with a gallerydir variable in my front matter set to the path of the directory containing my gallery images:

---
title: Big Event Photos
layout: gallery
gallerypath: events/images/big-event
---

Check out some photos from our Big Event!

When the page is processed, the template code loops through the entire static_files collection, checks whether the path of each file falls within the gallerydir, and if so, links to it in the output.  I use a lightbox script (GLightbox, in this specific case) to allow the user to browse the images in a pleasing way.

Eleventy doesn’t have this concept of static files.  Eleventy only processes the types of files you tell it to look at and ignores everything else.  If you want Eleventy to copy static files, you have to tell it to do so by using eleventyConfig.passthroughFileCopy() or something similar.  While this will get the files into your site, they won’t be automatically added to any collections.

To build the list of gallery images, and keep them separate from the rest of the site’s images, I moved all of the gallery images into a “galleries” directory.  Within that I created subdirectories of images for each gallery.  Then I used the NodeJS package fast-glob to find those files.

First, fast-glob has to be installed:

npm install --save-dev fast-glob

Then, it needs to be imported within .eleventy.js:

const fastglob = require("fast-glob")

And then we call it from inside the module.exports routine within .eleventy.js to build a list of our gallery images:

const galleries = fastglob.sync(["**/galleries/*/*.*", "!_site"])

This sucks the paths of any file that has the parent directory structure of galleries/{some gallery name} into an array. (The second parameter, !_site, tells fast-glob to ignore any file paths that are already copied to the _site directory; flast-glob doesn’t understand the Eleventy file structure, so it doesn’t know to ignore _site.)  To actually use it, we need to create a new Eleventy collection. To do that, we also add this to module.exports:

eleventyConfig.addCollection("galleries", function (collection) {
    let items = galleries.map((x) => {
        let paths = x.split("/")
        return {
            gallery: parts[parts.length - 2],
            path: x,
            name: [parts.length -1]
        }
    })
    return items
})

This takes the list of image paths and turns it into a set of objects with three properties: gallery (the name of the gallery the image is within, pulled from the last directory name in the path), path (the original path of the image), and name (the filename of the image, which I’m not actually using right now, but I figured my be useful to know in the future).  This list of objects is used to populate a new “galleries” collection in Eleventy.

With this new collection, I can update my gallery layout to look more like this:

{{ content }}

<div class="gallery">
    {% assign images = collections.galleries | where: "gallery", gallery %}
    {% for images in images %}
        <div class="gallery-image"><a href="{{ image.path }}" class="gallery-link"><img src="{{ image.path }}"></a></div>
    {% endfor %}
</div>

And in my page’s front matter, I replace gallerypath with gallery, and assign it the name of the gallery (i.e. the directory name within one of my site’s “galleries” directories) I want to show:

---
title: Big Event Photos
layout: gallery
gallery: big-event
---

Check out some photos from our Big Event.

it’s important to note that fast-glob only returns a list of files that match the pattern, it does not copy them to the site automatically.  In my case, an existing passthroughFileCopy() for all JPEG images does the trick,  but we could also update the map function inside the addCollection() to handle this if we wanted.  As a future extension to this concept, I may look at using Eleventy’s Image plugin to automatically resize my images to ideal dimensions, but in my current use case, all of my images have already been manually resized.

So that’s how it did it.  This method gets me to feature parity on the Eleventy site, it still needs some work.  As it stands now, neither the Jekyll or the Eleventy solution is accessible.  I need a way to add additional information, like alt text for the images, to the galleries.  The obvious solution is probably to add a CSV file to the site’s _data directory to store this information, but then I could just loop through that instead of using the file glob, so maybe this whole approach isn’t really needed at all.  We shall see.

Recreating Jekyll’s _drafts Directory in Eleventy

I’m in the process of converting a couple of sites that I built a few years ago using Jekyll to use Eleventy instead. Both tools are static site generators that work very similarly, but Eleventy gives me more flexibility and, given that it’s based in JavaScript—a language I use daily—rather than Ruby—a language I know almost nothing about—Eleventy is much easier for me to extend and customize to my needs.

Jekyll has a unique feature that Eleventy does not: the drafts folder. In Jekyll, you can add content that isn’t ready for public consumption to a directory named _drafts , and when you build the site, this content will be ignored. To include the content, you add a --drafts argument to the jekyll build or jekyll serve command.

While I never really made much use of this feature, the site I’m converting now does have a drafts directory with a couple of in-progress pages. Eleventy doesn’t have the concept of draft content, so I wanted to find a workaround, at least for the time being.

Eleventy has a few ways to ignore files from being processed. First, anything inside node_modules is automatically ignored. Then, anything in .gitignore (at least by default) or .eleventyignore files gets excluded, but adding the _drafts directory to one of these would mean it would never be processed. I need a way to selectively tell Eleventy to build the draft content when I want it and ignore it when I don’t.

Fortunately, there is a simple solution: Eleventy’s ignores collection, which is automatically populated from the files above. Eleventy conveniently provides an API for adding or removing paths from the collection on a programatic basis. To make the drafts folder work, I added the following inside the module.exports in my site’s .eleventy.js file:

if(process.env.ELEVENTY_ENV === "production") {
    eleventyConfig.ignores.add("_drafts")
}

This looks for an environment variable named ELEVENTY_ENV with the value production and, if found, adds the file glob “_drafts” to the list of ignored content. This has the effect of ignoring anything located in any directory named _drafts located anywhere within the site when that environment value is present. If the ELEVENTY_ENV variable is not set, or it contains a different value, the draft content will be processed.

I’m already using ELEVENTY_ENV to manage minification and map file creation for CSS and client-side JavaScript assets, so this works well for me. In fact, I don’t really have to think much about it because I’ve incorporated it into the npm run scripts in my `package.json`:

"scripts": {
"build": "npm run clean; ELEVENTY_ENV=production npx @11ty/eleventy",
"build-test": "npx @11ty/eleventy",
"watch": "npx @11ty/eleventy --watch",
"serve": "npx @11ty/eleventy --serve",
"clean": "rm -rf _site/*"
}

This means that drafts will be excluded if I nmp run build the site, but not during development when I’m most likely using npm run serve. If I want to exclude that content during development for some reason, it’s just a matter of running ELEVENTY_ENV=production npm run serve.

Dynamic Autocomplete with AlpineJS and (Almost) No Code

I’m in the process of adding some new features to a web application I created several years ago.  It’s an app that makes it easy for a handful of non-technical users to manage users and groups in a third-party system.  It’s a multipage app that doesn’t use a lot of JavaScript, but where it does it uses jQuery (don’t judge, we were all using jQuery when this thing was written).

I don’t have time to completely refactor the entire app, but I’d like to start the process of moving away from jQuery, so I figured I’d avoid using it for the new functionality.  AlpineJS is one of my favorite JavaScript libraries right now, and I figured it would be the perfect tool to use for this project, since it would give me modern, reactive-style support while still working within the confines of the existing multipage framework.  Alpine can do most of what I need pretty easily–things like modals and input checking.  One of the things that I didn’t have a good answer for, however, was the autocomplete.

In the current version of the application, adding users is done by selecting a group and entering a username into a form field.  Of course we don’t expect that the application’s users will necessarily know the usernames of the people they’re adding, so I added a jQueryUI autocomplete which is tied to a script that does an LDAP search and returns a list of names.  As the user types in the field, a list of possible people pops up and, when one is selected, the proper username is entered into the field and the form can be submitted.

The new functionality that I am adding also needs a user lookup.  Of course, there are lots of “vanilla” autocompletes out there that I could use, but ideally I’d like to limit the number of extra libraries I need to include.  I’ve also been working on another project lately that involves form processing with JavaScript and, at some point when I was looking at the MDN site for something, I was reminded of the HTML 5 <datalist> element.

If you aren’t aware, <datalist> lets you create a static list of options, similar to a <select> element, that can be attached to an <input>.  Unlike a <select>, however, the <datalist> list is only a list of suggestions; values that are not in the list can still be entered.

A <datalist> looks a lot like a <select>:

<datalist id="animals">
  <option>Dog</option>
  <option>Cat</option>
  <option>Mouse</option>
</datalist>

It can also accept a key-value list, just like a <select>.  The only difference is that when an item is selected, the value shown in the field will be that of the value attribute rather than the label text.

<datalist id="animals">
  <option value="dog">Dog</option>
  <option value="cat">Cat</option>
  <option value="mouse">Mouse</option>
</datalist>

A <datalist> is tied to an <input> element by adding a list attribute to the <input> element. The list attribute should be set to the id of the <datalist> that’s to be used:

<input type="text" name="animal" list="animals" />

Normally the <datalist> would contain static values that are included in the page when it is rendered on the server, but this wouldn’t work for my use case…it would be impractical to include all 20,000 or so user accounts that we have on every page load.  Instead I need to build the list dynamically.  This is where AlpineJS comes in.

First, I need a datasource.  As I mentioned above, the app already has an endpoint, “/lookup” that is used by jQueryUI.  This takes a query string parameter “term” and returns a JSON array that looks similar to this:

[
  {
    "value": "jsmith",
    "label": "John Smith (jsmith, Student)",
  },
  {
    "value": "sjones",
    "label": "Susan Jones (sjones, Faculty)",
  },
]

Next, I need AlpineJS.  In this case I’m using both the AlpineJS base library as well as Alpine Fetch, a third-party plugin that helps with fetching remote data into Alpine.  As with all Alpine plugins, Alpine Fetch needs to be included before the base library.

<script defer src="https://gitcdn.link/cdn/hankhank10/alpine-fetch/main/alpine-fetch.js"></script>
<script src="//unpkg.com/alpinejs" defer></script>

Now I can create the AlpineJS component.  That looks like this:

<div x-data="{
    results: null,
    term: null
}">
  <label for="username">Username:</label>
  <input type="text" id="username" name="username" list="userlist" 
    x-model="term"
    @keypress.throttle="results = await $fetch('/lookup?term=' + term)"
  />
  <datalist id="userlist"> 
    <template x-for="item in JSON.parse(results)">
      <option :value="item.value" x-text="item.label"></option>
    </template>
  </datalist>
</div>

That’s it!  We now have a fully working autocomplete.

Let me break down what’s going on here:

  1. In the first line, the x-data attribute on the <div> signals to Alpine that we are creating a new component.  The value of that attribute contains the default values for variables that we’ll be using within the component.
    • results contains the results that were received the last time the /lookup endpoint was queried.  It will contain a JSON string.  We don’t have any initial data, so it is initialized to null.
    • term will contain the term that is being searched.  It will be linked to the value of the input element, but since we aren’t starting with an initial value, it is also set to null.
  2. The <input> element on line 6 is where most of the interaction occurs:
    • We link the field to the <datalist> using the list="userlist" attribute.
    • The x-model="term” attribute establishes a two-way linkage between the field’s value and the term variable we initialized in x-data.  This means that any time the field value is changed, the variable will be updated to reflect it.  Likewise, if the variable is ever changed directly (which never happens in this context), the field value will also change to reflect it.
    • Finally, the @keypress attribute sets an event handler that calls /lookup with the current value of the field each time a key is pressed.  The .throttle modifier is used to limit these calls to no more than once every 250 ms to prevent flooding the server.  $fetch() is a magic method provided by Alpine Fetch that makes a web request and returns the result body as a string, which we store in the results variable we created in x-data.
  3. Alpine watches for changes to variables and reacts to them, so once we get new results, the x-for loop in the <template> on line 11 gets triggered.  This creates new <option> tags within the <datalist> for each result in the returned JSON data, replacing any that were there previously.  Since results contains the raw string that was returned from the web request, we call JSON.parse() on it to parse it into a JavaScript array.
  4. On each <option> tag that’s created, the :value="item.value" attribute tells Alpine to set a value attribute with the value from the result item and the x-text="item.label" tells it to set the element’s innerText to the value of the result item’s label.

So far this approach seems to work great.  The only downside is that each browser has its own way to format the <datalist> display, and there’s no way to customize it with CSS.  That’s not a big deal to me in an app that only has a handful of users, but it might be if it’s used on a large, public-facing, well-branded site.  If that’s the case, it probably wouldn’t be too difficult to modify this approach to use, say, an absolute-positioned <ul> list, the way more traditional autocomplete utilities do things, though that would require a couple of additional event handlers, some ARIA tags to ensure accessibility, and a bunch of CSS.

I’m An Engineer

From time to time when I was a kid, my grandparents would take me to Santa’s Land, a Christmas themed theme park in southern Vermont.

On one of those trips, I remember buying a mechanical Christmas tree toy in the gift shop. The tree was made of three aluminum triangle-ish segments that were mounted to a disk. When you pushed in on the lever on the handle, the disk would spin and the centrifugal force would cause the tree segments to pull apart, revealing a Santa inside. I remember looking at the all the gears that made the toy work and thinking, “I could take this apart and put it back together” (because that’s how my brain works sometimes), and I proceeded to disassemble the thing one afternoon at the table on my grandparents’ back porch.

Of course, getting it back together wasn’t as easy as I thought it would be, and when my grandmother came out to see the toy she had bought me turned into a pile of components, she just shook her head and said “you’re going to be an engineer someday.”

My grandmother said I’d become an engineer many times. I have no idea if this particular incident was the first time; it probably wasn’t and it definitely wasn’t the last, but it’s the memory that sticks with me. I, of course, didn’t go into engineering, I went into software, ultimately getting a degree in Information Technology. Software engineering was still a pretty new discipline when I was in college, and by the time I graduated most companies were still using variations of “programmer/analyst” for their software job titles.

“Software engineer” has, of course, become a much more common job title now, and I’ve been calling myself a software engineer for several years, but I’ve never had anything official to back it up. That changed recently as a result of a project to reevaluate and standardize job titles and descriptions across my organization at work. We’ve had systems engineers and network engineers for years, but on the software side we’ve had programmer/analysts (or in my case, “technical team lead,” though what that meant was never well defined and tended to vary based on who I was reporting to at any given time). The applications development managers successfully argued that, since the other teams had engineers, their teams should have software engineers, and it worked. Our job titles got updated last week.

So grandma, you were right…I did become an engineer. And now it’s official. I’m sure you’re proud of me.

My Decision to Move to US Mobile: Nine Months In

About nine months ago it was time for new phones, and I decided that it was also a good time to leave Verizon after being a Verizon post-paid customer for probably 15 years. The Verizon network is the best bet in this area, especially in the more rural areas I often visit (or at least used to visit, pre-pandemic), such as at my mom’s house and the Adirondacks, but Verizon is expensive.  We were on a legacy plan, with two lines, 6Gb of shared data, and a decent discount that I got through work which, with our phones paid off, was running us about $95 a month.

With both of us having access to Wi-Fi at work (and me now working from home and almost never using data), we were pretty much never using anywhere close to all of the data we had available, but whenever I looked at Verizon’s updated plans, it would have actually cost me more to drop to a smaller plan.  That’s when I decided to start looking at Verizon-based mobile virtual network operators (MVNOs).

After looking around a bit, I stumbled on US Mobile.  I hadn’t heard of them before, but I liked that they had a build-your-own-plan approach, similar to what Ting used to offer, letting you pick exactly how many minutes and text messages, and how much data you think you’ll need at the start of the month.

US Mobile operates on two mobile networks.  Nowhere on their site do they actually say what networks they are, but it’s not hard to figure out that their “SuperLTE” network is Verizon and their “GSM LTE” network is T-Mobile.  5G is supported on all plans, if it’s available and your phone supports it.  When you order their starter kit, they send a SIM card for each network so you can test them both out and choose the one that works the best.

Based on our average usage with Verizon, I built a plan that included unlimited talk and text and 1.5 and 2.5 Gb of data per respective phone (at the time, US Mobile didn’t offer shared data, so you needed to pick a specific allotment for each phone).  That first month cost us about $56 total, so we saved just under half of what we were paying Verizon.  After a couple of months with them, however, US Mobile began offering a set of new “unlimited” plans including one that offered unlimited talk and text and 2.5Gb of data for $15.  This turned out to be even more economical for us, bringing our bill to a few cents under $40 once you add in the $2/month line access fee and 15% regulatory cost recovery fee.  Even better, US Mobile has increased the amount of data in this $15 plan every few months, from 2.5Gb to 3.5Gb and now 5Gb, so now we’re getting more data than we did with Verizon for half the price.

Just this month, US Mobile added shared data options.  With these, you pay $9 per line for unlimited talk and text and then add data for $2/Gb that’s shared across all lines.  I haven’t thought about switching yet, but I could probably shave off a few more dollars if I did.  Using our old Verizon plan as a model, two lines with 6Gb of data would cost $30.96—a third of the cost of the old Verizon plan—but I’d need to look at the numbers to ensure our data usage hasn’t changed and…well…I’m just not sure it’s really worth it at this point.

US Mobile also offers “unlimited everything” plans for as little as $25, if you have at least three of these lines on your account.  If you only need one line, though, they’re a bit more expensive than other carriers, at $45.  Unlimited everything plans are full-speed, but don’t include hot spot access (though you can add it for $10 more).  You can also get a “perk” with three unlimited everything lines (like free Netflix or Hulu/Disney+/ESPN+), and two perks with four lines.

US Mobile is primarily a BYOD, or bring-your-own-device, provider.  They do sell phones, but most of the phones they offer are older or less powerful models.  The two Android devices they had when we switched were both about two years old, for example.  That’s OK, though, because you can buy almost any unlocked phone on the market and bring it to at least one of US Mobile’s networks.  Since our Galaxy S7 were quite long in the tooth when we switched, we bought new, unlocked S20s at Best Buy and they work great on US Mobile’s SuperLTE network.

US Mobile has no contracts and plans are good for 30 days, after which you are free to choose an entirely different plan if you so desire.  If you underestimate your needs, you can add “top ups” to get more text messages, phone minutes, or data and these top ups rollover to the next month automatically, so long as you keep the line active.  Unlike the big phone companies who generally charge a premium for extra data, top ups cost the same as when buying them initially.

I was a bit gun-shy about switching providers, so I decided to order a small plan on a new number to give US Mobile a try for one month.  After US Mobile’s support team assured me that they’d be able to reset the SIM when I was ready to port my number, I signed up for a handful of minutes and texts and 1.5Gb of data and started playing.  All of my speed tests were comparable with my Verizon plan and reliability seemed just as good, so we decided to make the switch.

Porting our numbers did have a couple of hiccups.  To port out, Verizon requires that you go to a page in their customer portal and create a PIN.  You then provide this PIN to US Mobile.  The same PIN is good for all lines on the account.  When I created my PIN, I didn’t bother two write it down, as I thought I’d be making two quick copy-pastes and be done, but the Verizon site logged me out before I got to the second phone, causing me to lose the PIN.  That left me on the phone with Verizon for a good 45 minutes while they tried to figure out what to do, though they were able to finally figure out what the PIN was and provide it to me over the phone.

On the US Mobile side, our phones didn’t activate automatically, as they should have.  This was easily corrected through a quick support chat, and within a few minutes we were in business.  They say that a port can take up to 48 hours to complete, but ours both happened within minutes, although the fact that we weren’t actually leaving the Verizon network probably helped.

There are a few benefits to Verizon’s service that we’ve lost with US Mobile, but they’re minor.  First, there’s no Wi-Fi calling, at least not on SuperLTE.  This isn’t a huge deal, but it does mean that you end up using your cell data if you’re browsing the web while talking on the phone.  I’ve also discovered that I can’t use Wegmans’ Scan app to scan my groceries from my phone while I’m talking on it, since the app requires that you be on the store’s Wi-Fi.

International roaming is a bit different, too.  When we were on Price Edward Island for my sister’s vet school graduation, Verizon’s TravelPass was great—for $5/day we had access to our regular phone number for calls and texts, which made it easy to coordinate meetups with other family members who were staying in different hotels.  While US Mobile supports international roaming, it’s for data only and requires a phone with an eSIM (which ours supposedly have, though the feature seems to be disabled).  If we were going to, say, take a trip to the falls or to Toronto for a weekend, I’d have to buy an international data plan (for Canada it’s $10 for 1Gb or $30 for 5Gb) and then I’d have to forward my calls to my Google Voice number or my home VOIP number if I wanted to be able to receive them (and since you can’t automatically forward text messages, I wouldn’t be able to get those until I got back over the border).  But I don’t see myself venturing to Canada any time soon—not until they start allowing non-essential border crossings at least, and when we do go, it’s usually just for the day, so not having our phones isn’t a big deal.

I have to say that I’ve been nothing but happy with my choice to switch to US Mobile.  The service has been just as reliable as it was with Verizon and, while I haven’t travelled to any super remote places like the Adirondacks lately, I’ve had great service everywhere around here that I’ve been, including in some of the rural hill towns.  While there are a few gotchas that we had to deal with, they were all minor, and US Mobile’s support has been phenomenal, at all hours of the day.

Interested in trying US Mobile?  I have a limited number of promo codes available that will give you 50% off your first plan, up to $10 and will give me a similar discount on a future bill of mine.  Hit me up in the comments if you’d like me to share and I’ll reach out to you on the email address you provide.

<