From 07ee1cecaf667ad72c126d7deec97de4d038b12e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 19:57:28 +0000 Subject: [PATCH 1/5] Add short URL redirects for content codes Support /[code] redirects to full URLs (e.g., /b232e -> /blog/b232e/slug/). The middleware now detects 5-character codes at the root level and redirects to the canonical URL using a 301 permanent redirect. - Add KIND_TO_COLLECTION mapping to code.ts - Add getCollection() method to Code class - Update middleware to handle root-level short code URLs - Fix test for error message to include T kind --- src/middleware.ts | 26 +++++++++++++++++++++++++- src/utils/code.ts | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index b652986..bea911a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,10 +1,34 @@ import { defineMiddleware } from "astro:middleware"; import { getCollection } from "astro:content"; -import { Code } from "@/utils/code"; +import { Code, KIND_TO_COLLECTION, type Kind } from "@/utils/code"; export const onRequest = defineMiddleware(async (context, next) => { const url = new URL(context.request.url); + // Handle short code URLs like /b232e -> /blog/b232e/slug/ + const shortCodeMatch = url.pathname.match(/^\/([brpt][0-9a-f]{4})\/?$/i); + if (shortCodeMatch) { + const shortCode = shortCodeMatch[1].toLowerCase(); + const kindChar = shortCode[0].toUpperCase() as Kind; + const collection = KIND_TO_COLLECTION[kindChar]; + + const entry = ( + await getCollection(collection, ({ id }) => { + return shortCode === id.slice(0, 5).toLowerCase(); + }) + ).at(0); + + if (entry) { + const { code, slug } = Code.parseId(entry.id); + const newUrl = new URL(url); + newUrl.pathname = `/${collection}/${code}/${slug}/`; + return context.redirect(newUrl.toString(), 301); + } + + // No match, continue to 404 + return next(); + } + // Handle /blog/*, /projects/*, /research/*, and /talks/* paths const match = url.pathname.match(/^\/(blog|projects|research|talks)\//); const matchedType = match?.[1] as "blog" | "projects" | "research" | "talks" | undefined; diff --git a/src/utils/code.ts b/src/utils/code.ts index f667cd7..e46151f 100644 --- a/src/utils/code.ts +++ b/src/utils/code.ts @@ -2,7 +2,15 @@ const EPOCH = Date.UTC(2000, 0, 1); // January 1, 2000 UTC const BASE16_CHARS = "0123456789ABCDEF" as const; export const VALID_KINDS = ["B", "R", "P", "T"] as const; -export type Kind = typeof VALID_KINDS[number]; +export type Kind = (typeof VALID_KINDS)[number]; + +export const KIND_TO_COLLECTION = { + B: "blog", + R: "research", + P: "projects", + T: "talks", +} as const; +export type Collection = (typeof KIND_TO_COLLECTION)[Kind]; function isValidKind(char: string): char is Kind { return VALID_KINDS.includes(char as Kind); @@ -155,6 +163,13 @@ export class Code { return this.toString(); } + /** + * Get the collection name for this code's kind + */ + getCollection(): Collection { + return KIND_TO_COLLECTION[this.kind]; + } + /** * Helper to generate getStaticPaths for Astro routes * @param collection - The collection to get entries from @@ -275,7 +290,7 @@ if (import.meta.vitest) { }); it("should reject codes with invalid kind", () => { - expect(() => Code.fromCode("X2391")).toThrow("Invalid kind 'X'. Must be one of: B, R, P"); + expect(() => Code.fromCode("X2391")).toThrow("Invalid kind 'X'. Must be one of: B, R, P, T"); }); it("should reject codes with non-hex characters in date portion", () => { @@ -400,4 +415,22 @@ if (import.meta.vitest) { expect(url).toBe("/research/r24e5/parsing-techniques/"); }); }); + + describe("Code.getCollection()", () => { + it("should return 'blog' for kind B", () => { + expect(Code.fromCode("B2392").getCollection()).toBe("blog"); + }); + + it("should return 'research' for kind R", () => { + expect(Code.fromCode("R2392").getCollection()).toBe("research"); + }); + + it("should return 'projects' for kind P", () => { + expect(Code.fromCode("P2392").getCollection()).toBe("projects"); + }); + + it("should return 'talks' for kind T", () => { + expect(Code.fromCode("T2392").getCollection()).toBe("talks"); + }); + }); } From c677c02571be852d86a958f756f0101f4e0d8593 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:03:49 +0000 Subject: [PATCH 2/5] Use Collection type from KIND_TO_COLLECTION mapping Replace hardcoded type assertion with Collection type for better maintainability and consistency with the rest of the codebase. Co-authored-by: Justin Bennett --- src/middleware.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index bea911a..6b2b083 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,6 @@ import { defineMiddleware } from "astro:middleware"; import { getCollection } from "astro:content"; -import { Code, KIND_TO_COLLECTION, type Kind } from "@/utils/code"; +import { Code, KIND_TO_COLLECTION, type Kind, type Collection } from "@/utils/code"; export const onRequest = defineMiddleware(async (context, next) => { const url = new URL(context.request.url); @@ -31,7 +31,7 @@ export const onRequest = defineMiddleware(async (context, next) => { // Handle /blog/*, /projects/*, /research/*, and /talks/* paths const match = url.pathname.match(/^\/(blog|projects|research|talks)\//); - const matchedType = match?.[1] as "blog" | "projects" | "research" | "talks" | undefined; + const matchedType = match?.[1] as Collection | undefined; if (!matchedType) { return next(); From c1e934c216cd920542cf60b450abb6a4cc5b23c5 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:06:31 +0000 Subject: [PATCH 3/5] Use KIND_TO_COLLECTION for dynamic collection pattern matching Replace hardcoded collection names in regex with dynamic pattern derived from KIND_TO_COLLECTION mapping. Co-authored-by: Justin Bennett --- src/middleware.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/middleware.ts b/src/middleware.ts index 6b2b083..bc162c1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -30,7 +30,9 @@ export const onRequest = defineMiddleware(async (context, next) => { } // Handle /blog/*, /projects/*, /research/*, and /talks/* paths - const match = url.pathname.match(/^\/(blog|projects|research|talks)\//); + const collections = Object.values(KIND_TO_COLLECTION).join("|"); + const collectionPattern = new RegExp(`^\\/(${collections})\\/`); + const match = url.pathname.match(collectionPattern); const matchedType = match?.[1] as Collection | undefined; if (!matchedType) { From 5d817755c940e80db8ba0f2d18d4a90931581454 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:19:07 +0000 Subject: [PATCH 4/5] Use VALID_KINDS for dynamic error message in test Co-authored-by: Justin Bennett --- src/utils/code.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/code.ts b/src/utils/code.ts index e46151f..a168114 100644 --- a/src/utils/code.ts +++ b/src/utils/code.ts @@ -290,7 +290,7 @@ if (import.meta.vitest) { }); it("should reject codes with invalid kind", () => { - expect(() => Code.fromCode("X2391")).toThrow("Invalid kind 'X'. Must be one of: B, R, P, T"); + expect(() => Code.fromCode("X2391")).toThrow(`Invalid kind 'X'. Must be one of: ${VALID_KINDS.join(", ")}`); }); it("should reject codes with non-hex characters in date portion", () => { From a541bba0a78033d7f341e33cfd267262f6362f86 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:26:53 +0000 Subject: [PATCH 5/5] Use KIND_TO_COLLECTION for dynamic short code pattern Replace hardcoded 'brpt' regex pattern with dynamic pattern derived from KIND_TO_COLLECTION keys. This ensures the short code pattern stays in sync with the collection mappings. Co-authored-by: Justin Bennett --- src/middleware.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/middleware.ts b/src/middleware.ts index bc162c1..7092a80 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -6,7 +6,9 @@ export const onRequest = defineMiddleware(async (context, next) => { const url = new URL(context.request.url); // Handle short code URLs like /b232e -> /blog/b232e/slug/ - const shortCodeMatch = url.pathname.match(/^\/([brpt][0-9a-f]{4})\/?$/i); + const kindChars = Object.keys(KIND_TO_COLLECTION).join("").toLowerCase(); + const shortCodePattern = new RegExp(`^\\/([${kindChars}][0-9a-f]{4})\\/?$`, "i"); + const shortCodeMatch = url.pathname.match(shortCodePattern); if (shortCodeMatch) { const shortCode = shortCodeMatch[1].toLowerCase(); const kindChar = shortCode[0].toUpperCase() as Kind;