Skip to content

Commit ffbf3c3

Browse files
just-be-devclaudegithub-actions[bot]
authored
Add short URL redirect with type prefix routing (#137)
* 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 * 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 <just-be-dev@users.noreply.github.com> * 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 <just-be-dev@users.noreply.github.com> * Use VALID_KINDS for dynamic error message in test Co-authored-by: Justin Bennett <just-be-dev@users.noreply.github.com> * 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 <just-be-dev@users.noreply.github.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Justin Bennett <just-be-dev@users.noreply.github.com>
1 parent e854630 commit ffbf3c3

2 files changed

Lines changed: 66 additions & 5 deletions

File tree

src/middleware.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,41 @@
11
import { defineMiddleware } from "astro:middleware";
22
import { getCollection } from "astro:content";
3-
import { Code } from "@/utils/code";
3+
import { Code, KIND_TO_COLLECTION, type Kind, type Collection } from "@/utils/code";
44

55
export const onRequest = defineMiddleware(async (context, next) => {
66
const url = new URL(context.request.url);
77

8+
// Handle short code URLs like /b232e -> /blog/b232e/slug/
9+
const kindChars = Object.keys(KIND_TO_COLLECTION).join("").toLowerCase();
10+
const shortCodePattern = new RegExp(`^\\/([${kindChars}][0-9a-f]{4})\\/?$`, "i");
11+
const shortCodeMatch = url.pathname.match(shortCodePattern);
12+
if (shortCodeMatch) {
13+
const shortCode = shortCodeMatch[1].toLowerCase();
14+
const kindChar = shortCode[0].toUpperCase() as Kind;
15+
const collection = KIND_TO_COLLECTION[kindChar];
16+
17+
const entry = (
18+
await getCollection(collection, ({ id }) => {
19+
return shortCode === id.slice(0, 5).toLowerCase();
20+
})
21+
).at(0);
22+
23+
if (entry) {
24+
const { code, slug } = Code.parseId(entry.id);
25+
const newUrl = new URL(url);
26+
newUrl.pathname = `/${collection}/${code}/${slug}/`;
27+
return context.redirect(newUrl.toString(), 301);
28+
}
29+
30+
// No match, continue to 404
31+
return next();
32+
}
33+
834
// Handle /blog/*, /projects/*, /research/*, and /talks/* paths
9-
const match = url.pathname.match(/^\/(blog|projects|research|talks)\//);
10-
const matchedType = match?.[1] as "blog" | "projects" | "research" | "talks" | undefined;
35+
const collections = Object.values(KIND_TO_COLLECTION).join("|");
36+
const collectionPattern = new RegExp(`^\\/(${collections})\\/`);
37+
const match = url.pathname.match(collectionPattern);
38+
const matchedType = match?.[1] as Collection | undefined;
1139

1240
if (!matchedType) {
1341
return next();

src/utils/code.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ const EPOCH = Date.UTC(2000, 0, 1); // January 1, 2000 UTC
22
const BASE16_CHARS = "0123456789ABCDEF" as const;
33

44
export const VALID_KINDS = ["B", "R", "P", "T"] as const;
5-
export type Kind = typeof VALID_KINDS[number];
5+
export type Kind = (typeof VALID_KINDS)[number];
6+
7+
export const KIND_TO_COLLECTION = {
8+
B: "blog",
9+
R: "research",
10+
P: "projects",
11+
T: "talks",
12+
} as const;
13+
export type Collection = (typeof KIND_TO_COLLECTION)[Kind];
614

715
function isValidKind(char: string): char is Kind {
816
return VALID_KINDS.includes(char as Kind);
@@ -155,6 +163,13 @@ export class Code {
155163
return this.toString();
156164
}
157165

166+
/**
167+
* Get the collection name for this code's kind
168+
*/
169+
getCollection(): Collection {
170+
return KIND_TO_COLLECTION[this.kind];
171+
}
172+
158173
/**
159174
* Helper to generate getStaticPaths for Astro routes
160175
* @param collection - The collection to get entries from
@@ -275,7 +290,7 @@ if (import.meta.vitest) {
275290
});
276291

277292
it("should reject codes with invalid kind", () => {
278-
expect(() => Code.fromCode("X2391")).toThrow("Invalid kind 'X'. Must be one of: B, R, P");
293+
expect(() => Code.fromCode("X2391")).toThrow(`Invalid kind 'X'. Must be one of: ${VALID_KINDS.join(", ")}`);
279294
});
280295

281296
it("should reject codes with non-hex characters in date portion", () => {
@@ -400,4 +415,22 @@ if (import.meta.vitest) {
400415
expect(url).toBe("/research/r24e5/parsing-techniques/");
401416
});
402417
});
418+
419+
describe("Code.getCollection()", () => {
420+
it("should return 'blog' for kind B", () => {
421+
expect(Code.fromCode("B2392").getCollection()).toBe("blog");
422+
});
423+
424+
it("should return 'research' for kind R", () => {
425+
expect(Code.fromCode("R2392").getCollection()).toBe("research");
426+
});
427+
428+
it("should return 'projects' for kind P", () => {
429+
expect(Code.fromCode("P2392").getCollection()).toBe("projects");
430+
});
431+
432+
it("should return 'talks' for kind T", () => {
433+
expect(Code.fromCode("T2392").getCollection()).toBe("talks");
434+
});
435+
});
403436
}

0 commit comments

Comments
 (0)