Hands-On: Keep a Short Link Through a Source Move
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.
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.
Join the Discussion
Comments for this post live in GitHub Discussions. That keeps moderation in one place and gives the conversation a stable home.