diff --git a/packages/web/app/lib/slug-utils.ts b/packages/web/app/lib/slug-utils.ts index 91874222..7ab44ab5 100644 --- a/packages/web/app/lib/slug-utils.ts +++ b/packages/web/app/lib/slug-utils.ts @@ -1,7 +1,7 @@ import { dbz } from '@/app/lib/db/db'; import { BoardName, LayoutId, Size } from '@/app/lib/types'; import { matchSetNameToSlugParts } from './slug-matching'; -import { generateSlugFromText, generateDescriptionSlug } from './url-utils'; +import { generateSlugFromText, generateDescriptionSlug, generateLayoutSlug } from './url-utils'; import { UNIFIED_TABLES } from '@/app/lib/db/queries/util/table-select'; import { eq, and, isNull } from 'drizzle-orm'; @@ -35,27 +35,8 @@ export const getLayoutBySlug = async (board_name: BoardName, slug: string): Prom const layout = rows.find((l) => { if (!l.name) return false; - const baseSlug = l.name - .toLowerCase() - .trim() - .replace(/^(kilter|tension)\s+board\s+/i, '') // Remove board name prefix - .replace(/[^\w\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - - let layoutSlug = baseSlug; - - // Handle Tension board specific cases - if (baseSlug === 'original-layout') { - layoutSlug = 'original'; - } - - // Replace numbers with words for better readability - if (baseSlug.startsWith('2-')) { - layoutSlug = baseSlug.replace('2-', 'two-'); - } - + // Use shared helper that normalizes Unicode dashes to ASCII + const layoutSlug = generateLayoutSlug(l.name); return layoutSlug === slug; }); diff --git a/packages/web/app/lib/url-utils.ts b/packages/web/app/lib/url-utils.ts index fb75c6c4..f9c7ab9a 100644 --- a/packages/web/app/lib/url-utils.ts +++ b/packages/web/app/lib/url-utils.ts @@ -303,7 +303,7 @@ export const constructCreateClimbWithSlugs = ( }; export const generateClimbSlug = (climbName: string): string => { - return climbName + return normalizeDashes(climbName) .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') @@ -312,12 +312,25 @@ export const generateClimbSlug = (climbName: string): string => { .replace(/^-|-$/g, ''); }; +/** + * Normalizes various dash-like Unicode characters to ASCII hyphen. + * This handles en dash (–), em dash (—), non-breaking hyphen (‑), minus sign (−), etc. + */ +const normalizeDashes = (text: string): string => { + // Replace various Unicode dash/hyphen characters with ASCII hyphen + // U+2010 Hyphen, U+2011 Non-breaking hyphen, U+2012 Figure dash + // U+2013 En dash, U+2014 Em dash, U+2015 Horizontal bar + // U+2212 Minus sign, U+FE58 Small em dash, U+FE63 Small hyphen-minus + // U+FF0D Fullwidth hyphen-minus + return text.replace(/[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/g, '-'); +}; + /** * Generates a slug from a text string by normalizing it. * This is a shared helper used by size slug generation. */ export const generateSlugFromText = (text: string): string => { - return text + return normalizeDashes(text) .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') @@ -331,7 +344,7 @@ export const generateSlugFromText = (text: string): string => { * This is a shared helper used by size slug generation. */ export const generateDescriptionSlug = (description: string): string => { - return description + return normalizeDashes(description) .toLowerCase() .replace(/led\s*kit/gi, '') // Remove "LED Kit" suffix .trim() @@ -342,7 +355,7 @@ export const generateDescriptionSlug = (description: string): string => { }; export const generateLayoutSlug = (layoutName: string): string => { - const baseSlug = layoutName + const baseSlug = normalizeDashes(layoutName) .toLowerCase() .trim() .replace(/^(kilter|tension)\s+board\s+/i, '') // Remove board name prefix