Nginx maps: How I redirect sketch IDs to blog posts or sketches

| geek, tech

Assumed audience: I'm mostly writing these notes for myself, because I'd forgotten how this actually works. Maybe it might be interesting for people who also like identifying their posts or sketches and who use Nginx.

Since August 2022, I've been identifying sketches with YYYY-MM-DD-NN, where NN is a number derived from my journaling system. I used to use just YYYY-MM-DD and the title, but sometimes I renamed sketches. Then I used YYYY-MM-DDa (ex: 2022-08-01f). Sometimes optical character recognition had a hard time distinguishing my hand-drawn "d" from "a", though. Numbers were generally more reliable. And besides, that way, there's room for growth in case I have more than 26 journal entries or sketches in a day. (Not that I've gone anywhere near that volume.)

Anyway, I want to have short URLs like sachachua.com/2025-07-31-10 or sach.ac/2025-07-31-10 go to a blog post if one's available, or just the sketch if I haven't written about it yet, like sach.ac/2025-01-16-01. Here's how I do it.

To publish my site, I export HTML files from Org Mode and I use the 11ty static site generator to put them together in a blog.

One of my 11ty files is map.11ty.cjs, which has the following code:

map.11ty.cjs
module.exports = class AllPosts {
  data() {
    return {
      permalink: '/conf/nginx.map',
      eleventyExcludeFromCollections: true
    };
  }
  async render (data) {
    let list = data.collections._posts;
    let cats = data.siteCategoriesWithParents;
    let nested = cats.filter(o => o.parent);
    let hash = {};
    await list.reduce(async (prev, o) => {
      await prev;
      if (o.data?.zid) {
        hash[o.data.zid] = o.url;
      }
      let matches = (await o.template.inputContent).matchAll(/{% +sketchFull +\"([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9](-[0-9][0-9]|[a-z]))?/g);
      for (const m of matches) {
        if (m[1] && !hash[m[1]]) {
          hash[m[1]] = o.url;
        }
      }
    });
    let byZIDs = Object.entries(hash).map((o) => `/${o[0]} ${o[1]};\n`).join('');
    let byBlogID = list.filter(o => o.data.id).map((o) => `/blog/p/${o.data.id} ${o.url};`).join("\n") + "\n";
    let nestedCategories = nested.map((o) => {
      let slugPath = o.parentPath.map((p) => p.slug).join('/') + '/' + o.slug;
      let catPage = `/blog/category/${slugPath} /blog/category/${o.slug};\n`;
      let catFeed = `/blog/category/${slugPath}/feed/ /blog/category/${o.slug}/feed/;\n`;
      let catAtom = `/blog/category/${slugPath}/feed/atom/ /blog/category/${o.slug}/feed/atom/;\n`;
      return catAtom + catFeed + catPage;
    }).join('');
    const result = byBlogID + nestedCategories + byZIDs;
    return result;
  }
};

The code analyzes each of my blog posts and looks for full-sized sketches. If it finds a full-size sketch, it writes a rule that redirects that ID to that blog post. This is what part of the resulting nginx.map looks like:

/2025-07-31-10 /blog/2025/08/monthly-review-july-2025/;
/2025-08-04-01 /blog/2025/08/turning-42-life-as-a-41-year-old/;
/2025-08-03-08 /blog/2025/08/turning-42-life-as-a-41-year-old/;
/2025-08-31-01 /blog/2025/08/emacs-elevator-pitch-tinkerers-unite/;

In my Nginx web server config (nginx.conf), I have:

http {
  # ...
  include /etc/nginx/redirections.map;
  # ...
}

And that redirections.map uses the map module and looks like this:

map $request_uri $new_uri {
   default "";
   include /var/www/static-blog/conf/nginx.map;
   / /blog;
   # ...
}

Then in my Nginx site configuration, I redirect to that $new_uri if available. As a fallback, I also detect yyyy-mm-dd-nn URLs and redirect them to my sketchbook.

server {
   # ... other rules
   if ($new_uri) {
       return 302 $new_uri;
   }
   location ~ ^/([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9](a-z|-[0-9][0-9])?)$ {
      return 302 https://sketches.sachachua.com/$1;
   }
}

The Node app that runs sketches.sachachua.com searches by ID if it gets one. Here's the code in the Express router:

router.get('/:filename', controller.serveImagePageByFilename);

which calls this controller:

exports.serveImagePageByFilename = async (req, res) => {
  if (req.params.filename) {
    let f = await findImageByFilename(req.params.filename);
    if (f) {
      renderSingle(req, res, f);
    } else {
      res.sendStatus(404);
    }
  } else {
    res.sendStatus(404);
  }
};

which uses this function:

const findImageByFilename = (filename) => {
  return getSketches().then((sketches) => {
    let id = filename.match(/^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]([a-z]|-[0-9][0-9])/);
    let m = filename.match(/^[^#]+/);
    let file = sketches.find((x) => path.parse(x.filename).name == filename);
    if (!file) {
      file = sketches.find((x) => x.filename.startsWith(id));
    }
    if (!file && m) {
      file = sketches.find((x) => x.filename.startsWith(m[0]));
    }
    if (!file) {
      file = sketches.find((x) => path.parse(x.filename).name.replace(' #.*') == filename);
    }
    if (!file) {
      file = sketches.find((x) => x.filename.startsWith(filename));
    }
    return file;
  });
};

(getSketches just reads the JSON I've saved.)

So my workflow for a public sketch is:

  1. Draw the sketch.
  2. In my journaling system, write the title and tags for the sketch. Get an ID.
  3. Write the ID on the sketch.
  4. Export the sketch from the iPad as a JPEG.
  5. Wait for things to sync in the background.
  6. Process the sketch. I can use my-image-rename-current-image-based-on-id to rename it manually, or I can use my-sketch-process to recognize the ID and process the text. (My Emacs configuration)
  7. Wait for things to sync in the background. In the meantime, edit the text version of the sketch.
  8. Reload my public sketches, which regenerates the sketches.json that lists all the sketches.
  9. Use my-write-about-sketch or my-insert-sketch-and-text to include the sketch and the text in a draft blog post. (My Emacs configuration) Do my usual blogging process.
View org source for this post