I put short links into this site so I could share articles on X/Twitter, Bluesky, and other places with tight post lengths without depending on an external redirect service. I want those links under our control, including when I change how the article’s page is named. If I calculate a short link from a source path and then reorganize the source, though, the same calculation gives me a different link. That is exactly what I was trying to avoid.

The Part 4 article explains the separate identities involved. Here I want to make one piece of that behavior visible: the site’s current short-link module, an invented post, and a simulated file move. The exercise is small enough to run without building this website.

Run the Module Against Invented Posts

Download the short-link lab and its SHA-256 checksum. The archive contains an unchanged snapshot of this site’s 01_short_link_injector.rb and a Ruby runner that supplies tiny stand-ins for Jekyll post and site objects. There are no real posts, credentials, or network calls in the lab.

You can inspect the material before running it:

README.md markdown View source
# Keep a short link through a source move

This small lab loads an unchanged snapshot of this site's `01_short_link_injector.rb` module and gives it invented post objects. It needs Ruby but no Jekyll install, network access, or copy of the real site's posts.

From the extracted `short-link-lab` directory, run:

```bash
ruby run_lab.rb
```

The runner reports three checks: a fixed `short_link_basis` keeps the same short URL after a simulated source move; without that field, the source-path fallback changes the code; and a declared `short_url` that disagrees with the computed URL is rejected. It also prints the invented short URL so you can compare runs.

The module is the active site's short-link calculation as inspected for Part 4. The runner uses tiny stand-ins for Jekyll post and site objects, so this is a test of the module's decision logic, not a Jekyll build. It does not generate redirect HTML or verify a deployed route. The site's post-build scripts handle generated files, and an operator still has to check public reachability separately.
run_lab.rb ruby View source
#!/usr/bin/env ruby
# frozen_string_literal: true

# Tiny stand-ins let the unchanged site module run without Jekyll gems.
module Jekyll
  class QuietLogger
    def error(*)
    end
  end

  def self.logger
    @logger ||= QuietLogger.new
  end

  module Errors
    class FatalException < StandardError; end
  end
end

require_relative "source/01_short_link_injector"

Post = Struct.new(:relative_path, :data)
PostCollection = Struct.new(:docs)
Site = Struct.new(:config, :posts, :pages)

ORIGIN = "https://site.example"
ORIGINAL_PATH = "_posts/2026-10-15-example.md"
MOVED_PATH = "_posts/series/example/2026-10-15-example.md"
FROZEN_BASIS = "/_posts/2026-10-15-example.md"

def inject(post)
  site = Site.new({ "short_link_origin" => ORIGIN }, PostCollection.new([post]), [])
  ShortLinkInjector.inject!(site)
  post.data.fetch("short_url")
end

def check(condition, label)
  raise "FAIL #{label}" unless condition

  puts "PASS #{label}"
end

before = Post.new(ORIGINAL_PATH, { "short_link_basis" => FROZEN_BASIS })
after = Post.new(MOVED_PATH, { "short_link_basis" => FROZEN_BASIS })
original_url = inject(before)
moved_url = inject(after)
check(original_url == moved_url && before.data.fetch("redirect_from") == after.data.fetch("redirect_from"), "frozen basis survives source move")

fallback_before = inject(Post.new(ORIGINAL_PATH, {}))
fallback_after = inject(Post.new(MOVED_PATH, {}))
check(fallback_before != fallback_after, "source-path fallback changes after move")

wrong = Post.new(MOVED_PATH, { "short_link_basis" => FROZEN_BASIS, "short_url" => "#{ORIGIN}/s/0000000000/" })
begin
  inject(wrong)
  raise "FAIL mismatched declared short_url was accepted"
rescue Jekyll::Errors::FatalException => error
  check(error.message.include?("short_url front matter does not match"), "mismatched declared short_url is rejected")
end

puts "Invented stable URL: #{original_url}"
01_short_link_injector.rb ruby View source
# frozen_string_literal: true

require "digest"

# Deterministic short links: /s/<10 hex>/ merged into each eligible item's
# `redirect_from`. Posts are eligible by default; pages opt in with
# `short_link: true` (or an existing `short_url`).
# (consumed by jekyll-redirect-from). Invoked from CasePreservingPermalinks#generate
# so it always runs after permalinks are set and before JekyllRedirectFrom::Generator.
#
# Hash input: short_link_origin + `short_link_basis`. If the immutable basis is
# absent, the source path under the site root remains the compatibility fallback.
module ShortLinkInjector
  extend self

  DEFAULT_ORIGIN = "https://unixwzrd.ai".freeze

  def inject!(site)
    origin = site.config.fetch("short_link_origin", DEFAULT_ORIGIN).to_s.chomp("/")
    code_to_item = {}

    items(site).each do |item|
      path = short_link_basis_path(item)
      digest = Digest::SHA256.hexdigest("#{origin}#{path}")
      code = digest[0, 10]
      short_path = "/s/#{code}/"
      label = item_label(item)

      if code_to_item.key?(code) && code_to_item[code] != label
        Jekyll.logger.error(
          "ShortLinkInjector:",
          "collision: code #{code} for #{label} and #{code_to_item[code]}",
        )
        raise Jekyll::Errors::FatalException, "short_link: hash collision for /s/#{code}/"
      end
      code_to_item[code] = label

      expected_short_url = "#{origin}#{short_path}"
      if item.data["short_url"] && !item.data["short_url"].to_s.strip.empty?
        unless short_url_matches?(item.data["short_url"].to_s, origin, code)
          Jekyll.logger.error(
            "ShortLinkInjector:",
            "short_url mismatch in #{label}: expected #{expected_short_url}, got #{item.data['short_url']}",
          )
          raise Jekyll::Errors::FatalException, "short_url front matter does not match computed short URL"
        end
      end
      item.data["short_url"] = expected_short_url

      existing = item.data["redirect_from"]
      list =
        case existing
        when nil then []
        when Array then existing.dup
        else [existing]
        end
      list << short_path unless list.include?(short_path)
      item.data["redirect_from"] = list
    end
  end

  def items(site)
    pages = site.pages.select { |page| page_short_link_enabled?(page) }
    site.posts.docs + pages
  end

  def page_short_link_enabled?(page)
    page.data["short_link"] == true || !page.data["short_url"].to_s.strip.empty?
  end

  # @param item [Jekyll::Document, Jekyll::Page]
  # @return [String] path starting with /, no trailing slash (e.g. /_posts/foo.md)
  def short_link_basis_path(item)
    configured = item.data["short_link_basis"].to_s.strip
    return validate_basis!(configured, item) unless configured.empty?

    rel = item.relative_path.to_s.tr("\\", "/").delete_prefix("/")
    validate_basis!("/#{rel}", item)
  end

  def validate_basis!(basis, item)
    normalized = basis.to_s.tr("\\", "/")
    invalid = !normalized.start_with?("/") || normalized.end_with?("/") ||
      normalized.include?("?") || normalized.include?("#") ||
      normalized.split("/").include?("..")
    return normalized unless invalid

    label = item_label(item)
    Jekyll.logger.error "ShortLinkInjector:", "invalid short_link_basis in #{label}: #{basis.inspect}"
    raise Jekyll::Errors::FatalException, "short_link_basis must be a source-like absolute path"
  end

  def item_label(item)
    item.relative_path.to_s.tr("\\", "/")
  end

  def short_url_matches?(raw, origin, code)
    s = raw.to_s.strip.sub(%r{/+\z}, "").downcase
    expected = "#{origin}/s/#{code}".sub(%r{/+\z}, "").downcase
    s == expected
  end
end

Put the ZIP and checksum together, then run:

shasum -a 256 -c jekyll-short-link-lab.zip.sha256
unzip jekyll-short-link-lab.zip
cd short-link-lab
ruby run_lab.rb

The first check makes two invented posts with different relative_path values but the same frozen short_link_basis. They get the same short URL and the same /s/ redirect path. The second check omits the basis, so moving the source changes the hash input and the code. The third declares a wrong short_url and checks that the module rejects it.

For project posts, a canonical URL, declared legacy redirect, and basis-derived short redirect are separate paths to the same article. This lab exercises only the short-link calculation.
Open full-size diagram

This lab exercises the short-link branch, not the complete Jekyll redirect build.

What That Result Means

The runner uses the actual module’s hash and validation logic. It does not ask Jekyll to write redirect pages. The public site still needs its normal build, its post-build redirect checks, and a look at the served result. This lab shows why I store the basis before I start moving files around; it does not certify a deployed link.

It also shows a trap in the word deterministic. A hash can be perfectly deterministic and still produce a new address if I change its input. The immutable input is the useful part of the design.

Current State

With a frozen basis, the invented source move leaves the short link alone. Without it, the fallback path moves and the link changes. The module catches a declared short_url that disagrees with its calculation. All three cases run without modifying the website.

Next Work

Part 5 follows the project itself from authored YAML into generated data, cards, pages, menus, and project blog lists. That is where stable routes become one part of a larger project publishing path.