From 681d1bcc38b9aa473c6535c2a635445f417a1bef Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 1 Oct 2026 15:19:36 +0800 Subject: [PATCH 1/2] Navigate SPA writing links through the migrated archive --- src/router.ts | 3 ++ src/support/archive-navigation.ts | 18 ++++++++ tests/support/archive-navigation.test.ts | 53 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 src/support/archive-navigation.ts create mode 100644 tests/support/archive-navigation.test.ts diff --git a/src/router.ts b/src/router.ts index 201dc7b8..a52607e2 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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(); @@ -54,4 +55,6 @@ const router: Router = createRouter({ ], }); +router.beforeEach(createArchiveNavigationGuard()); + export default router; diff --git a/src/support/archive-navigation.ts b/src/support/archive-navigation.ts new file mode 100644 index 00000000..1312591f --- /dev/null +++ b/src/support/archive-navigation.ts @@ -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; + } + }; +}; diff --git a/tests/support/archive-navigation.test.ts b/tests/support/archive-navigation.test.ts new file mode 100644 index 00000000..f2017cdf --- /dev/null +++ b/tests/support/archive-navigation.test.ts @@ -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: '
' }; + 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); + }); +}); From 2fa93c105dbcc4228e6b9ee7c87383f2b0724aae Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 1 Oct 2026 15:45:19 +0800 Subject: [PATCH 2/2] Add canonical legacy post redirects to web Caddy --- caddy/WebCaddyfile.internal | 26 +++++++++++++++++++ tests/caddy/archive-redirects.test.ts | 24 +++++++++++++++++ tests/fixtures/writing-archive-redirects.json | 26 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 tests/caddy/archive-redirects.test.ts create mode 100644 tests/fixtures/writing-archive-redirects.json diff --git a/caddy/WebCaddyfile.internal b/caddy/WebCaddyfile.internal index e7b3c0d5..2269624b 100644 --- a/caddy/WebCaddyfile.internal +++ b/caddy/WebCaddyfile.internal @@ -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 { diff --git a/tests/caddy/archive-redirects.test.ts b/tests/caddy/archive-redirects.test.ts new file mode 100644 index 00000000..eb02863b --- /dev/null +++ b/tests/caddy/archive-redirects.test.ts @@ -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); + }); +}); diff --git a/tests/fixtures/writing-archive-redirects.json b/tests/fixtures/writing-archive-redirects.json new file mode 100644 index 00000000..326ac596 --- /dev/null +++ b/tests/fixtures/writing-archive-redirects.json @@ -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" +}