Add slugify utility for filename sanitization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
69
frontend/src/utils/__tests__/slugify.spec.ts
Normal file
69
frontend/src/utils/__tests__/slugify.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
28
frontend/src/utils/slugify.ts
Normal file
28
frontend/src/utils/slugify.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const UMLAUT_MAP: Record<string, string> = {
|
||||
ä: '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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user