Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions caddy/WebCaddyfile.internal
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,32 @@
respond 405
}

# --- Retired writing archive: canonical redirects for human and bot requests ---
redir /post/2026-02-19-local-first-skills-platform-for-ai-agents https://writing.gocanto.sh/local-first-skills-platform-for-ai-agents 301
redir /post/2023-06-21-bugs-are-inevitable-in-software-development https://writing.gocanto.sh/bugs-are-inevitable-in-software-development 301
redir /post/2025-09-25-shipping-seo-for-a-single-page-app-the-pragmatic-way https://writing.gocanto.sh/shipping-seo-for-a-single-page-app-the-pragmatic-way 301
redir /post/2025-10-09-when-a-real-time-feature-store-is-the-wrong-fix-for-fraud https://writing.gocanto.sh/when-a-real-time-feature-store-is-the-wrong-fix-for-fraud 301
redir /post/2025-11-04-your-ai-model-is-not-your-product https://writing.gocanto.sh/your-ai-model-is-not-your-product 301
redir /post/2026-04-05-go-meets-the-monolith-renaissance https://writing.gocanto.sh/go-meets-the-monolith-renaissance 301
redir /post/2025-12-29-nostalgia-is-a-security-risk https://writing.gocanto.sh/nostalgia-is-a-security-risk 301
redir /post/2025-11-21-demystifying-the-go-maps-engine https://writing.gocanto.sh/demystifying-the-go-maps-engine 301
redir /post/2025-12-15-money-in-Go-done-properly https://writing.gocanto.sh/money-in-go-done-properly 301
redir /post/2026-03-13-taming-complex-state-in-go https://writing.gocanto.sh/taming-complex-state-in-go 301
redir /post/2026-03-31-laravel-collections-to-go https://writing.gocanto.sh/laravel-collections-to-go 301
redir /post/2025-10-29-turbocharging-web-performance-automated-early-hints https://writing.gocanto.sh/turbocharging-web-performance-automated-early-hints 301
redir /post/2025-11-13-shipping-observability-for-oullin-infrastructure https://writing.gocanto.sh/shipping-observability-for-oullin-infrastructure 301
redir /post/2025-11-26-the-case-of-the-mismatched-clocks-lesson-secure-timing https://writing.gocanto.sh/the-case-of-the-mismatched-clocks-lesson-secure-timing 301
redir /post/2026-02-26-stop-installing-python-just-to-convert-files-to-markdown https://writing.gocanto.sh/stop-installing-python-just-to-convert-files-to-markdown 301
redir /post/2025-11-15-debugging-multi-layered-docker-deployment https://writing.gocanto.sh/debugging-multi-layered-docker-deployment 301
redir /post/2023-06-22-positive-emotions-reduce-negative-emotions https://writing.gocanto.sh/positive-emotions-reduce-negative-emotions 301
redir /post/2025-04-02-embrace-growth-through-movement https://writing.gocanto.sh/embrace-growth-through-movement 301
redir /post/2025-10-17-the-operating-system-of-you-an-engineering-leader-reflection https://writing.gocanto.sh/the-operating-system-of-you-an-engineering-leader-reflection 301
redir /post/2025-11-03-honesty-establishes-trust-enabling-process-formation-progression https://writing.gocanto.sh/honesty-establishes-trust-enabling-process-formation-progression 301
redir /post/2025-11-30-work-is-not-family-and-that-is-a-good-thing https://writing.gocanto.sh/work-is-not-family-and-that-is-a-good-thing 301
redir /post/2026-02-24-engineering-management-mistakes-we-need-to-stop-making.md https://writing.gocanto.sh/engineering-management-mistakes-we-need-to-stop-making 301
redir /post/2025-12-05-handling-money-in-php https://writing.gocanto.sh/handling-money-in-php 301
redir /post/2025-10-15-building-oullin https://writing.gocanto.sh/building-oullin 301

# --- Normalise trailing slashes ("/about/" -> "/about") ---
@has_trailing path_regexp trailing ^/(.+)/$
@has_trailing_with_query {
Expand Down
3 changes: 3 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, Router } from 'vue-router';
import { scrollBehavior } from '@support/scrolls';
import { createArchiveNavigationGuard } from '@support/archive-navigation';

const routerHistory = createWebHistory();

Expand Down Expand Up @@ -54,4 +55,6 @@ const router: Router = createRouter({
],
});

router.beforeEach(createArchiveNavigationGuard());

export default router;
18 changes: 18 additions & 0 deletions src/support/archive-navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { NavigationGuard } from 'vue-router';

const WRITING_ARCHIVE_URL = 'https://writing.gocanto.sh/';

export const createArchiveNavigationGuard = (navigate: (url: string) => void = (url) => window.location.assign(url)): NavigationGuard => {
return (to, from) => {
if (to.name === 'Writing' || to.name === 'TagPosts') {
navigate(WRITING_ARCHIVE_URL);
return false;
}

if (to.name === 'PostDetail' && from.matched.length > 0) {
// A document request lets Caddy apply each legacy post's canonical redirect.
navigate(to.fullPath);
return false;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
};
24 changes: 24 additions & 0 deletions tests/caddy/archive-redirects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import canonicalRedirects from '../fixtures/writing-archive-redirects.json';

const caddyfile = readFileSync(resolve(process.cwd(), 'caddy/WebCaddyfile.internal'), 'utf8');
const redirects = [...caddyfile.matchAll(/^\s*redir (\/post\/\S+) (\S+) (\d+)$/gm)];

// These targets match the existing API Caddy archive migration, including its slug corrections.
describe('production legacy writing redirects', () => {
it.each(Object.entries(canonicalRedirects))('redirects %s to its canonical archive target for humans', (path, target) => {
const rule = redirects.find((match) => match[1] === path);
expect(rule?.[2]).toBe(target);
expect(rule?.[3]).toBe('301');
expect(rule?.index).toBeLessThan(caddyfile.indexOf('@force_seo'));
expect(rule?.index).toBeLessThan(caddyfile.indexOf('@share_post_bots'));
});

it('only redirects mapped posts and keeps the SPA fallback for other routes', () => {
expect(redirects.map((match) => match[1]).toSorted()).toEqual(Object.keys(canonicalRedirects).toSorted());
expect(caddyfile).not.toMatch(/redir \/post\/\*/);
expect(caddyfile).toMatch(/handle\s*{\s*root \* \/usr\/share\/caddy\s*try_files \{path\} \{path\}\/index\.html \/index\.html/s);
});
});
26 changes: 26 additions & 0 deletions tests/fixtures/writing-archive-redirects.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"/post/2026-02-19-local-first-skills-platform-for-ai-agents": "https://writing.gocanto.sh/local-first-skills-platform-for-ai-agents",
"/post/2023-06-21-bugs-are-inevitable-in-software-development": "https://writing.gocanto.sh/bugs-are-inevitable-in-software-development",
"/post/2025-09-25-shipping-seo-for-a-single-page-app-the-pragmatic-way": "https://writing.gocanto.sh/shipping-seo-for-a-single-page-app-the-pragmatic-way",
"/post/2025-10-09-when-a-real-time-feature-store-is-the-wrong-fix-for-fraud": "https://writing.gocanto.sh/when-a-real-time-feature-store-is-the-wrong-fix-for-fraud",
"/post/2025-11-04-your-ai-model-is-not-your-product": "https://writing.gocanto.sh/your-ai-model-is-not-your-product",
"/post/2026-04-05-go-meets-the-monolith-renaissance": "https://writing.gocanto.sh/go-meets-the-monolith-renaissance",
"/post/2025-12-29-nostalgia-is-a-security-risk": "https://writing.gocanto.sh/nostalgia-is-a-security-risk",
"/post/2025-11-21-demystifying-the-go-maps-engine": "https://writing.gocanto.sh/demystifying-the-go-maps-engine",
"/post/2025-12-15-money-in-Go-done-properly": "https://writing.gocanto.sh/money-in-go-done-properly",
"/post/2026-03-13-taming-complex-state-in-go": "https://writing.gocanto.sh/taming-complex-state-in-go",
"/post/2026-03-31-laravel-collections-to-go": "https://writing.gocanto.sh/laravel-collections-to-go",
"/post/2025-10-29-turbocharging-web-performance-automated-early-hints": "https://writing.gocanto.sh/turbocharging-web-performance-automated-early-hints",
"/post/2025-11-13-shipping-observability-for-oullin-infrastructure": "https://writing.gocanto.sh/shipping-observability-for-oullin-infrastructure",
"/post/2025-11-26-the-case-of-the-mismatched-clocks-lesson-secure-timing": "https://writing.gocanto.sh/the-case-of-the-mismatched-clocks-lesson-secure-timing",
"/post/2026-02-26-stop-installing-python-just-to-convert-files-to-markdown": "https://writing.gocanto.sh/stop-installing-python-just-to-convert-files-to-markdown",
"/post/2025-11-15-debugging-multi-layered-docker-deployment": "https://writing.gocanto.sh/debugging-multi-layered-docker-deployment",
"/post/2023-06-22-positive-emotions-reduce-negative-emotions": "https://writing.gocanto.sh/positive-emotions-reduce-negative-emotions",
"/post/2025-04-02-embrace-growth-through-movement": "https://writing.gocanto.sh/embrace-growth-through-movement",
"/post/2025-10-17-the-operating-system-of-you-an-engineering-leader-reflection": "https://writing.gocanto.sh/the-operating-system-of-you-an-engineering-leader-reflection",
"/post/2025-11-03-honesty-establishes-trust-enabling-process-formation-progression": "https://writing.gocanto.sh/honesty-establishes-trust-enabling-process-formation-progression",
"/post/2025-11-30-work-is-not-family-and-that-is-a-good-thing": "https://writing.gocanto.sh/work-is-not-family-and-that-is-a-good-thing",
"/post/2026-02-24-engineering-management-mistakes-we-need-to-stop-making.md": "https://writing.gocanto.sh/engineering-management-mistakes-we-need-to-stop-making",
"/post/2025-12-05-handling-money-in-php": "https://writing.gocanto.sh/handling-money-in-php",
"/post/2025-10-15-building-oullin": "https://writing.gocanto.sh/building-oullin"
}
53 changes: 53 additions & 0 deletions tests/support/archive-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createMemoryHistory, createRouter } from 'vue-router';
import { describe, expect, it, vi } from 'vitest';
import { createArchiveNavigationGuard } from '@support/archive-navigation';

const setup = async (initialPath = '/') => {
const navigate = vi.fn();
const component = { template: '<div />' };
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'Home', component },
{ path: '/writing', name: 'Writing', component },
{ path: '/tags/:tag', name: 'TagPosts', component },
{ path: '/post/:slug', name: 'PostDetail', component },
{ path: '/about', name: 'About', component },
{ path: '/contact', name: 'Contact', component },
{ path: '/projects', name: 'Projects', component },
],
});
router.beforeEach(createArchiveNavigationGuard(navigate));
await router.push(initialPath);
return { router, navigate };
};

describe('writing archive navigation', () => {
it.each(['/writing', '/writing/', '/writing?category=go', '/tags/go'])('sends %s to the migrated archive', async (path) => {
const { router, navigate } = await setup();
await router.push(path);
expect(navigate).toHaveBeenCalledWith('https://writing.gocanto.sh/');
expect(router.currentRoute.value.name).toBe('Home');
});

it('uses a document request for the canonical legacy post redirect, preserving query and hash', async () => {
const { router, navigate } = await setup();
const path = '/post/2025-10-15-building-oullin?source=nav#architecture';
await router.push({ name: 'PostDetail', params: { slug: '2025-10-15-building-oullin' }, query: { source: 'nav' }, hash: '#architecture' });
expect(navigate).toHaveBeenCalledWith(path);
expect(router.currentRoute.value.name).toBe('Home');
});

it('does not loop on a direct request for an unmapped post', async () => {
const { router, navigate } = await setup('/post/unmapped-post');
expect(navigate).not.toHaveBeenCalled();
expect(router.currentRoute.value.name).toBe('PostDetail');
});

it.each(['/about', '/contact', '/projects'])('keeps %s in the SPA', async (path) => {
const { router, navigate } = await setup();
await router.push(path);
expect(navigate).not.toHaveBeenCalled();
expect(router.currentRoute.value.path).toBe(path);
});
});
Loading