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
1 change: 1 addition & 0 deletions apps/web/app/api/stores/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ slu
const store = stores[0];
const coupons = await db.sql`
SELECT * FROM coupons WHERE store_id = ${store.id}
AND (expiry_date IS NULL OR expiry_date = '' OR expiry_date >= date('now'))
ORDER BY votes DESC, created_at DESC
`;
return NextResponse.json({ store, coupons });
Expand Down
21 changes: 19 additions & 2 deletions apps/web/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export const dynamic = 'force-dynamic';

import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { notFound, permanentRedirect } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import { getDb } from '@/lib/db';
import { closestSlug } from '@/lib/blog-slugs';
import { sanitizeBlogHtml } from '@/lib/sanitize-blog-html';

interface Post {
Expand Down Expand Up @@ -35,6 +36,16 @@ async function getPost(slug: string): Promise<Post | null> {
}
}

async function publishedSlugs(): Promise<string[]> {
try {
const rows = await getDb().sql`SELECT slug FROM blog_posts WHERE status = 'published'`;
return rows.map((r: { slug: string }) => r.slug);
} catch (error) {
console.error('[blog] failed to list slugs', error);
return [];
}
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
Expand All @@ -61,7 +72,13 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
if (!post) {
// An old slug goes to the post it meant (308), else the blog index.
const meant = closestSlug(slug, await publishedSlugs());
if (meant && meant !== slug) permanentRedirect(`/blog/${meant}`);
if (/^[a-z0-9-]+$/.test(slug)) permanentRedirect('/blog');
notFound();
}
const sanitizedContent = sanitizeBlogHtml(post.content);

return (
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/stores/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const getStore = cache(async (slug: string): Promise<{ store: Store; coupons: Co
const store = stores[0];
const coupons = await db.sql`
SELECT * FROM coupons WHERE store_id = ${store.id}
AND (expiry_date IS NULL OR expiry_date = '' OR expiry_date >= date('now'))
ORDER BY votes DESC, created_at DESC
`;
return { store, coupons };
Expand Down
44 changes: 44 additions & 0 deletions apps/web/lib/blog-slugs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/** Levenshtein distance, stopping early once it exceeds `max`. */
export function editDistance(a: string, b: string, max = Infinity): number {
if (Math.abs(a.length - b.length) > max) return max + 1;
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
const cur = [i];
let best = i;
for (let j = 1; j <= b.length; j++) {
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
best = Math.min(best, cur[j]);
}
if (best > max) return max + 1;
prev = cur;
}
return prev[b.length];
}

/** Store-name token: the first dash-separated word ("doordash-promo-..." -> "doordash"). */
const lead = (s: string) => s.split('-')[0];

/**
* The published slug an old or mistyped one meant: the closest by edit distance
* within a third of its length; failing that, the only slug sharing the leading
* store name (doordash-..., shutterfly-..., walgreens-...), else the shortest
* edit distance among those sharing it. Null when nothing plausible exists.
*/
export function closestSlug(slug: string, slugs: Iterable<string>): string | null {
const all = [...slugs];
if (all.includes(slug)) return slug;
const max = Math.max(3, Math.floor(slug.length / 3));
let best: string | null = null;
let bestD = max + 1;
for (const s of all) {
const d = editDistance(slug, s, bestD - 1);
if (d < bestD) {
best = s;
bestD = d;
}
}
if (best) return best;
const sameStore = all.filter((s) => lead(s) === lead(slug) && lead(slug).length >= 4);
if (!sameStore.length) return null;
return sameStore.sort((a, b) => editDistance(slug, a) - editDistance(slug, b))[0];
}
20 changes: 20 additions & 0 deletions test/blog-slugs.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'bun:test';
import { closestSlug } from '../apps/web/lib/blog-slugs.ts';

const SLUGS = [
'amazon-promo-code-workflow',
'cvs-photo-promo-code-workflow',
'walgreens-photo-coupon-code-workflow-2026',
'walgreens-photo-coupon-code-workflow-2026-guide',
'papa-johns-promo-codes-2026-checkout-workflow',
];
describe('closestSlug', () => {
it('maps an old slug to the post of the same store', () => {
expect(closestSlug('walgreens-coupon-code-workflow', SLUGS)).toBe('walgreens-photo-coupon-code-workflow-2026');
});
it('keeps an existing slug and returns null when no post is close', () => {
expect(closestSlug('cvs-photo-promo-code-workflow', SLUGS)).toBe('cvs-photo-promo-code-workflow');
expect(closestSlug('doordash-promo-codes-for-existing-users-2026-workflow', SLUGS)).toBeNull();
expect(closestSlug('encrypted-messaging-coupon-codes-private-deal-sharing', SLUGS)).toBeNull();
});
});
Loading