Article · 2026-02-26

Adding a Short Link System to a Purely Static Blog

The Problem

My blog post URLs have always been long:

https://yuxu.ge/blog/post.html?p=posts/gol/2026-02-25-cross-experiment-en

When I share them on social media, these links take up half the message and look cluttered. I wanted a short link system, something like this:

https://yuxu.ge/s/fewt3

But my blog is a purely static site (hosted on GitHub Pages), with no backend server. This means traditional short link solutions — where a request hits a server, which queries a database and issues a 302 redirect — are all off the table.

Design Approach

Without a server, the logic must occur at build time or in the browser.

The core idea is simple: generate a shortcode for each article at build time, then create a corresponding HTML file to handle the redirect. GitHub Pages will automatically route a request for /s/fewt3/ to /s/fewt3/index.html, so all I need to do is place a redirect page at that location.

Build Time (Node.js)
══════════════════════════════════════════════════

  [posts.json] ──► [build-shortlinks.js]
                        │
           ┌────────────┼────────────┐
           ▼            ▼            ▼
   shortlinks.json   /s/abcde/    /s/fewt3/
   (slug → code)     index.html   index.html
                     (redirect)    (redirect)

Browser Side
══════════════════════════════════════════════════

  Article page loads
       │
       ▼
  fetch shortlinks.json (one-time)
       │
       ▼
  Look up current slug → get shortcode
       │
       ▼
  Inject "Copy Short Link" button

Shortcode Generation Algorithm

The shortcode generation needs to meet two requirements: it must be deterministic (the same slug always produces the same code) and have a low collision rate.

I chose a solution based on SHA-256:

"posts/2026/2026-02-20-git-crypt-private-notes-zh"  (slug)
                    │
                    ▼ SHA-256
"a7f3e1b9c4d8..."                          (64-char hex string)
                    │
                    ▼ Take first 8 hex characters
"a7f3e1b9"
                    │
                    ▼ parseInt(..., 16)
2818769337
                    │
                    ▼ .toString(36)
"4g9kfp"
                    │
                    ▼ padStart(5, '0').slice(0, 5)
"4g9kf"                                    (final shortcode)

The core implementation is just three lines:

function generateShortCode(slug, offset = 0) {
    const hash = crypto.createHash('sha256').update(slug).digest('hex');
    const num = parseInt(hash.slice(offset, offset + 8), 16);
    return num.toString(36).padStart(5, '0').slice(0, 5);
}

Collision Handling

A 5-character base36 string offers roughly 60 million combinations (36^5 ≈ 60,466,176), making collisions unlikely. Still, a robust system must handle them.

My strategy is a sliding window: if the code generated from the hex slice [0:8] conflicts with an existing one, I try [8:16], then [16:24], and so on. A 64-character SHA-256 hex string provides 7 retry opportunities, which is more than enough.

for (const slug of slugs) {
    let offset = 0;
    let code = generateShortCode(slug, offset);
    while (codeToSlug[code] && codeToSlug[code] !== slug) {
        offset += 8;
        if (offset >= 56) {
            console.error(`FATAL: Cannot resolve collision for ${slug}`);
            process.exit(1);
        }
        code = generateShortCode(slug, offset);
    }
    codeToSlug[code] = slug;
    slugToCode[slug] = code;
}

In practice, this approach generated 224 codes for 224 articles with no collisions. By the birthday problem, collision probability is roughly n² / (2 × 60.5M) — about 0.04% for 224 articles, and roughly 17% for 5,000. The sliding window handles any that occur.

The Redirect Page

Each shortcode corresponds to an index.html file responsible for instantly redirecting to the original article:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/blog/2026/2026-02-20-git-crypt-private-notes-zh.html">
<link rel="canonical" href="https://yuxu.ge/blog/2026/...">
<title>Redirecting...</title>
</head>
<body>
<script>window.location.replace('/blog/2026/...');</script>
<noscript><p>Redirecting...</p></noscript>
</body>
</html>

I use both meta http-equiv="refresh" and window.location.replace(). The former handles users with JavaScript disabled; the latter is faster and avoids a history entry, so back-button users don't land on the redirect. The canonical tag marks the original URL as authoritative.

Frontend Integration

On the article page, I needed to display a "Copy Short Link" button next to the title.

async function injectShortLinkButton() {
    if (!postName) return;
    if (document.getElementById('short-link-btn')) return;

    // Lazy-load the mapping file (one-time)
    if (!window._shortlinks) {
        try {
            const resp = await fetch('/blog/shortlinks.json');
            window._shortlinks = await resp.json();
        } catch { window._shortlinks = {}; }
    }

    const code = window._shortlinks[postName];
    if (!code) return;
    const shortUrl = location.origin + '/s/' + code;

    // Inject the button after the h1
    const h1 = document.querySelector('article h1');
    if (!h1) return;
    const btn = document.createElement('button');
    btn.id = 'short-link-btn';
    btn.className = 'short-link-btn';
    btn.textContent = '🔗';
    btn.title = shortUrl;
    btn.onclick = async () => {
        await navigator.clipboard.writeText(shortUrl);
        btn.textContent = 'Copied!';
        setTimeout(() => btn.textContent = '🔗', 1500);
    };
    h1.after(btn);
}

For static HTML pages generated at build time, the button is injected directly into the HTML by build-static.js.

Link Persistence: Short Links That Never Break

After the system was up and running, a question emerged: what happens if I change the algorithm in the future?

For example, expanding from 5 to 6 characters would produce completely different codes, breaking every link I'd shared.

To solve this, I introduced a persistent lock file: _tools/shortlinks-locked.json.

Build Process
══════════════════════════════════════════════════

  shortlinks-locked.json    posts.json
  (locked slug→code map)      (all posts)
         │                       │
         └───────┬───────────────┘
                 ▼
         build-shortlinks.js
                 │
    ┌────────────┼────────────┐
    │            │            │
    ▼            ▼            ▼
  Use locked    New posts go   shortlinks-locked.json
  codes         through        (new codes appended
  directly      algorithm      automatically)

The core logic is:

  1. During the build, first load all mappings from the lock file.
  2. For any slug already in the lock file, use its existing shortcode directly without recalculating it.
  3. Only run the generation algorithm for new slugs.
  4. When new codes are generated, automatically write the complete map back to the lock file.
// Load locked mappings
const locked = loadLockedCodes();

// Use locked codes directly
for (const slug of slugs) {
    if (locked[slug]) {
        codeToSlug[locked[slug]] = slug;
        slugToCode[slug] = locked[slug];
    }
}

// Only generate codes for new slugs
for (const slug of slugs) {
    if (slugToCode[slug]) continue; // Already locked, skip
    // ... run generation algorithm ...
}

// Automatically lock new codes
if (!skipLock && newCount > 0) {
    fs.writeFileSync(LOCKED_JSON, JSON.stringify(slugToCode, null, 2) + '\n');
}

This way, no matter how I change the algorithm in the future, old links will always remain valid. A --no-lock parameter allows me to skip the locking step during debugging.

Build Integration

I added a step to my build.sh:

echo "=== Building Short Links ==="
node $TOOLS_DIR/build-shortlinks.js

And added cleanup steps to clear.sh:

rm -rf s/
rm -f blog/shortlinks.json

The output looks like this:

$ node _tools/build-shortlinks.js
Generated: 224 short links (224 locked, 0 new)
Generated: blog/shortlinks.json

Summary

This system embodies one principle: do everything at build time, zero cost at runtime. No database, no server, no API calls—just pre-generated HTML files and a JSON map.

The persistent lock file is the linchpin: it reconciles two seemingly contradictory needs—"change the algorithm anytime" and "links never break."

© 2026 Yuxu Ge ·