Building for Gemini with Eleventy

Posted Nov 8, 2023

I recently discovered Gemini, an interesting lightweight alternative to HTTP, complete with its own markup language called Gemtext. While it’s only a couple of years old at this point, it’s supposedly inspired by the Gopher protocol. I’ll have to take their word for it – Gopher died off before I started using computers. I love the possibilities afforded by the modern web, but I can understand yearning for an online experience that isn’t dominated by 25 MB page loads and cookie consent popups. Sometimes I wish I could browse the web entirely in Reader mode, and Gemini basically says “what if everything was like that by default?” It’s too minimalist to ever take off – and arguably too elitist – but Gemini is an interesting vision of what the web could have been if only we as a species could settle for less.

As a side project, I wanted to try bringing permortensen.com into Geminispace – a capsule in local parlance. I already use a static site generator called Eleventy to build this site, which is mostly a bunch of Markdown files and some HTML layouts. Eleventy usually transforms content into HTML, but it’s flexible enough to output pretty much any format – like Gemtext – if you tweak it enough. Some requirements for the Gemtext version:

  1. It has to use the same Markdown files without modification, as I don’t want to update every page and blog post
  2. It has to slot into my existing .eleventy.js configuration and be enabled with a single toggle, like an environment variable
  3. Both HTML and Gemtext builds have to produce a ready-to-publish output directory, as I don’t want any extra steps to my deployment workflow (I just push to main)

The rest of this post documents the process of adapting my site for Gemini. I couldn’t find any existing guides on the topic, so I wanted to share what I found out in case anyone else finds it interesting. Geminispace could probably use some more content…

Project layout

For the existing HTML site, my source directory structure looks something like this

permortensen.com/
  _layouts/
    default.html
    post.html
  _posts/
    post1.md
    post2.md
    ...
  .eleventy.js

Every post uses a layout template called post, which renders the title and date from the post’s front matter. This template itself uses a layout called default, which renders the <head> and <body> elements around it. These .html files are really Liquid templates that just happen to contain HTML. The .md files also contain some Liquid tags, such as for eleventy-img, so they are preprocessed with Liquid as well. This works out of the box, as Eleventy preprocesses both HTML and Markdown files with LiquidJS by default (probably to align with Jekyll, the OG static site generator).

About Gemtext conversion

Like HTML 30 years ago, Gemtext is all about documents linking to other documents, but with a tiny vocabulary. Whereas all of Markdown can translate into HTML, only a fraction of it can translate to Gemtext. There is no syntax for images, text decoration, or even inline links – every link has to occupy its own line, and its solution to showing an image is simply linking to one. These limitations make for a very consistent browsing experience but also makes Gemtext conversion a lossy operation, even if you’re using the absolute basics of Markdown syntax.

The most promising Markdown to Gemtext converter I’ve found is md2gemini, which is a Python module available on PyPI. It’s archived, but as of writing it still seems to work pretty well – I guess neither Markdown nor Gemtext have changed significantly since. Honorable mention to gemgen, which is similar but unfortunately has some bugs when rendering lists with links. Both projects do clever things to work around the limitations of Gemtext: Markdown links inside a paragraph are extracted and listed below it with their original label preserved, while images are replaced with an image link labeled by its alt text. It’s probably the best automated approach to this problem, though it can lead to some weird looking links when pulled out of their original context.

For instance, the paragraph you’re reading right now has a link in it. That would end up as the Gemtext

For instance, the paragraph you're reading right now has a link in it. That would end up as the Gemtext

=> https://example.com/    a link in it

which isn’t ideal. Some would argue that this is the wrong way to label your links anyway (and I would agree!), but it probably needs some getting used to even for hyperlink purists. md2gemini can also extract links as numbered footnotes, but then every link is unreadable on its own.

md2gemini can be installed with pip install md2gemini, if you have Python 3 installed. As Eleventy runs on Node, there’s no built-in way to bundle Python modules. My current approach is to just install md2gemini globally with pip, which is not ideal. You could presumably install it using a Python package manager invoked from a package.json post-install script, but then you’d still need to install Python. However you do it, the rest of this post assumes a working installation of md2gemini.

Generating Gemtext from Markdown content

Eleventy by default uses markdown-it to turn Markdown into HTML, but we now want md2gemini to turn Markdown into Gemtext instead. This is possible by overriding the engine for md files, passing Markdown to a child process through STDIN, and reading back Gemtext from STDOUT. Here I’m using the --plain switch to remove any inline text decoration syntax, which will otherwise render literally in Gemtext.

const { execFile } = require("node:child_process");

eleventyConfig.addExtension("md", {
  outputFileExtension: "gmi",
  compile: async function (inputContent) {
    return async (data) => {
      const gemtextContent = await new Promise((resolve, reject) => {
        const process = execFile(
          "md2gemini",
          ["--plain", "--links", "copy"],
          (error, stdout) => (error ? reject(error) : resolve(stdout))
        );
        process.stdin.end(inputContent);
      });

      return gemtextContent;
    };
  },
});

Unfortunately, overriding the default Markdown engine also disables the automatic Liquid preprocessing, so the output will contain raw Liquid syntax, if any. To fix this, we have to manually invoke LiquidJS, which will render to real Markdown. I can’t find a way to grab the built-in LiquidJS instance, so as a workaround we can tell Eleventy to use a custom instance and then use it to preprocess the templates ourselves. Eleventy will register any custom tags and filters on the instance you give it, so they should work the same in both built-ins and our custom Markdown-to-Gemtext engine.

const { execFile } = require("node:child_process");
const { Liquid } = require("liquidjs");
const liquid = new Liquid();

eleventyConfig.setLibrary("liquid", liquid);

// eleventyConfig.addFilter('post_url', ...);
// eleventyConfig.addShortcode('image', ...);

eleventyConfig.addExtension("md", {
  outputFileExtension: "gmi",
  compileOptions: {
    permalink: "raw",
  },
  compile: async function (inputContent) {
    return async (data) => {
      const markdownContent = await liquid.parseAndRender(inputContent, data);
      const gemtextContent = await new Promise((resolve, reject) => {
        const process = execFile(
          "md2gemini",
          ["--plain", "--links", "copy"],
          (error, stdout) => (error ? reject(error) : resolve(stdout))
        );
        process.stdin.end(markdownContent);
      });

      return gemtextContent;
    };
  },
});

By default, Eleventy will also attempt to render the permalink of each page using the page’s template engine to allow for dynamic permalinks. While it would be feasible to pass the links through Liquid alone (and skip Gemtext conversion), I’m not using dynamic permalinks and set permalink: "raw" to disable the feature altogether.

Now we’re transforming Markdown to (pretty decent) Gemtext. _site contains a bunch of .gmi files as expected, but they all contain a bunch of HTML around our content. What gives?

Replacing HTML layouts

It’s the layouts. While the Markdown files now transform into Gemtext, the layouts are written in HTML and can’t meaningfully reduce to Gemtext. We’ll have to replace the HTML layouts with Gemtext equivalents, by hand. For instance, _layouts/post.html gets a sibling called _layouts/post.gmi.

---
layout: default
---
# {{ title }}

Posted {{ page.date | date }}

{{ content }}

These are still just Liquid templates, except now they happen to contain Gemtext instead of HTML. As Gemtext has no concept of metadata or graphical layout, default.gmi will likely just consist of {{ content }} and perhaps some primary navigation links.

To make Eleventy use the .gmi layouts instead of the .html ones, we register gmi as an alias of liquid to get automatic Liquid rendering for .gmi files. We also remove html from the list of valid template formats in order to avoid processing the HTML layouts altogether. I haven’t found a way to actually remove it other than setting the template formats to a list where html is absent.

eleventyConfig.addExtension("gmi", { key: "liquid" });
eleventyConfig.setTemplateFormats(["md", "liquid"]);

Assuming all layouts are referenced without a file extension, these two lines replaces every HTML layout with its Gemtext equivalent without any modifications to content or front matter. Neat!

Odds and ends, bits and bobs

You might have noticed that I’ve been using only the configuration API for the entire thing, and there’s a reason for that. With this centralized approach, it’s straightforward to toggle Gemini behavior on and off in .eleventy.js, like with this environment variable.

module.exports = function (eleventyConfig) {
  // Common configuration here

  if (process.env.GEMINI) {
    // Gemini specific configuration here
  } else {
    // HTML specific configuration here
  }
};

Other things to consider customizing via this toggle:

  • CSS, as you can’t style Gemtext pages
  • Client-side JavaScript
  • Favicons and Open Graph images

If you’re publishing an RSS/Atom feed, there is a proposed convention for a Gemini equivalent, but some clients like Lagrange will automatically convert a regular feed for you. Such a feed will contain HTML links unless customized, which could be done by copying the toggle to a global JavaScript data file or configuration global data and then adapting the feed template accordingly.

eleventy-img generates HTML by default, which needs to be replaced. Gemtext conversion will turn any Markdown images into links, so {% image ... %} shortcodes should render as regular Markdown images instead of <img> or <picture> tags.

eleventyConfig.addShortcode("image", async (src, alt) => {
  const pluginImage = require("@11ty/eleventy-img");
  const metadata = await pluginImage(src);

  if (process.env.GEMINI) {
    const data = metadata.jpeg[metadata.jpeg.length - 1];
    return `![${alt}](${data.url})`;
  }

  return pluginImage.generateHTML(metadata);
});

As neither Markdown nor Gemtext supports responsive images, we’ll have to settle on a single image format – here I’ve chosen the highest quality JPEG for maximum compatibility.

Eleventy has a built-in convention where if a page’s permalink ends with /index.html, the filename is automatically removed for a nicer looking URL. This is HTML specific, so generated links, as well as the built-in page.url property, will end in /index.gmi. The behavior can be replicated for Gemtext using the (poorly documented) URL transform feature.

eleventyConfig.addUrlTransform(({ url }) => {
  const pattern = /\/index\.gmi$/i;
  return url.match(pattern) ? url.replace(pattern, "/") : undefined;
});

Eleventy is very flexible and no two setups are the same. There’s probably a bunch of edge cases that I haven’t considered (or even encountered), but this should get you most of the way.

Hosting and deployment

We’re spoiled for choice when it comes to web hosting. Netlify, Vercel, Cloudflare Pages, and countless others make static website hosting a breeze by automatically building and deploying on every git push. While these services could easily build a Gemini capsule, they wouldn’t be able to host it. With them, HTTP is not only the default. It’s the only protocol in town. Gemini is a different protocol altogether, which is not supported by any of the mainstream providers and pretty much requires a box running some sort of Gemini server program (which there is no shortage of).

You could do this on a Raspberry Pi or your home computer if you really wanted to, and it would certainly fit the “smol web” ethos. My current approach is a $5 Linode VM that runs Agate in a tmux session, coupled with a GitHub Actions workflow that builds the site with GEMINI=1 and deploys it with rsync. It’s the simplest approach I could find that doesn’t require me to do anything extra to deploy: I just push to main and the site updates itself in turn. The following workflow is inspired by a good blog post on the topic.

on:
  push:
    branches: ["main"]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - uses: actions/setup-python@v4
        with:
          python-version: "3.9"
      - uses: shimataro/ssh-key-action@v2
        with:
          key: ${{ secrets.GEMINI_SSH_KEY }}
          known_hosts: ${{ secrets.GEMINI_KNOWN_HOSTS }}
      - run: pip install md2gemini
      - run: npm ci
      - run: npx eleventy
        env:
          GEMINI: 1
          NODE_ENV: production
      - run: rsync -avh --delete ./_site/ $HOST:$CONTENT/
        env:
          HOST: ${{ vars.GEMINI_HOST }}
          CONTENT: ${{ vars.GEMINI_CONTENT }}

So for now, permortensen.com has a presence in Geminispace as well. If I’m still hosting the Gemini capsule by the time you read this – and I haven’t broken it somehow – you can find it at gemini.permortensen.com. Adapting the site for an alternative platform has been a fun challenge, and I hope this post inspires you to give it a shot as well.