From d4a1f0dc232f17f355254eece92efb383cb6c209 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 13 Mar 2026 21:39:41 +0100 Subject: [PATCH] Add slugify utility for filename sanitization Co-Authored-By: Claude Opus 4.6 --- frontend/src/utils/__tests__/slugify.spec.ts | 69 ++++++++++++++++++++ frontend/src/utils/slugify.ts | 28 ++++++++ 2 files changed, 97 insertions(+) create mode 100644 frontend/src/utils/__tests__/slugify.spec.ts create mode 100644 frontend/src/utils/slugify.ts diff --git a/frontend/src/utils/__tests__/slugify.spec.ts b/frontend/src/utils/__tests__/slugify.spec.ts new file mode 100644 index 0000000..5a7c7be --- /dev/null +++ b/frontend/src/utils/__tests__/slugify.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { slugify } from '../slugify' + +describe('slugify', () => { + it('converts to lowercase', () => { + expect(slugify('Hello World')).toBe('hello-world') + }) + + it('replaces spaces with hyphens', () => { + expect(slugify('foo bar baz')).toBe('foo-bar-baz') + }) + + it('transliterates German umlauts', () => { + expect(slugify('Ärger über Öl füßen')).toBe('aerger-ueber-oel-fuessen') + }) + + it('transliterates uppercase umlauts', () => { + expect(slugify('Ä Ö Ü')).toBe('ae-oe-ue') + }) + + it('transliterates ß', () => { + expect(slugify('Straße')).toBe('strasse') + }) + + it('removes non-ASCII characters after transliteration', () => { + expect(slugify('Café résumé')).toBe('caf-rsum') + }) + + it('replaces special characters with hyphens', () => { + expect(slugify('hello@world! #test')).toBe('hello-world-test') + }) + + it('collapses consecutive hyphens', () => { + expect(slugify('foo---bar')).toBe('foo-bar') + }) + + it('trims leading and trailing hyphens', () => { + expect(slugify('--hello--')).toBe('hello') + }) + + it('truncates to 60 characters', () => { + const long = 'a'.repeat(80) + expect(slugify(long).length).toBeLessThanOrEqual(60) + }) + + it('does not break mid-word when truncating', () => { + // 60 chars of 'a' should just be 60 a's (no word boundary issue) + const result = slugify('a'.repeat(65)) + expect(result.length).toBe(60) + expect(result).toBe('a'.repeat(60)) + }) + + it('handles empty string', () => { + expect(slugify('')).toBe('') + }) + + it('handles string that becomes empty after processing', () => { + expect(slugify('!@#$%')).toBe('') + }) + + it('handles emoji', () => { + const result = slugify('Party 🎉 time') + expect(result).toBe('party-time') + }) + + it('produces Sommerfest am See example from spec', () => { + expect(slugify('Sommerfest am See')).toBe('sommerfest-am-see') + }) +}) diff --git a/frontend/src/utils/slugify.ts b/frontend/src/utils/slugify.ts new file mode 100644 index 0000000..da28552 --- /dev/null +++ b/frontend/src/utils/slugify.ts @@ -0,0 +1,28 @@ +const UMLAUT_MAP: Record = { + ä: 'ae', + ö: 'oe', + ü: 'ue', + ß: 'ss', + Ä: 'Ae', + Ö: 'Oe', + Ü: 'Ue', +} + +export function slugify(input: string): string { + return ( + input + // Transliterate German umlauts + .replace(/[äöüßÄÖÜ]/g, (ch) => UMLAUT_MAP[ch] ?? ch) + .toLowerCase() + // Remove non-ASCII characters + .replace(/[^\x20-\x7E]/g, '') + // Replace non-alphanumeric characters with hyphens + .replace(/[^a-z0-9]+/g, '-') + // Collapse consecutive hyphens + .replace(/-{2,}/g, '-') + // Trim leading/trailing hyphens + .replace(/^-|-$/g, '') + // Truncate to 60 characters + .slice(0, 60) + ) +}