diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx index 265bd0c..04f27d6 100644 --- a/src/pages/Landing.tsx +++ b/src/pages/Landing.tsx @@ -9,7 +9,7 @@ import { Clock, Filter, Grid2X2 } from 'lucide-react'; const CATEGORIES = [ { name: 'Generators', slugs: ['uuid', 'nanoid', 'password', 'objectid', 'qr'] }, { name: 'Encoders & Decoders', slugs: ['base64', 'url', 'jwt-decode'] }, - { name: 'Converters & Formatters', slugs: ['json', 'color', 'json-to-ts'] }, + { name: 'Converters & Formatters', slugs: ['json', 'color', 'json-to-ts', 'case-converter'] }, { name: 'API & Networking', slugs: ['api-tester'] }, { name: 'Security & Crypto', slugs: ['hash', 'jwt'] }, { name: 'Utilities', slugs: ['timestamp', 'regex', 'markdown'] } diff --git a/src/tools/case-converter/CaseConverterTool.tsx b/src/tools/case-converter/CaseConverterTool.tsx new file mode 100644 index 0000000..e51800c --- /dev/null +++ b/src/tools/case-converter/CaseConverterTool.tsx @@ -0,0 +1,300 @@ +import { useState, useMemo, useEffect } from 'react'; +import { ToolLayout } from '../../components/ToolLayout'; +import { Button } from '../../components/ui/Button'; +import { CopyButton } from '../../components/ui/CopyButton'; +import { useUrlState } from '../../hooks/useUrlState'; +import { + convertAllCases, + detectCasing, + extractWords, + toCamelCase, + toPascalCase, + toSnakeCase, + toConstantCase, + toKebabCase, + toSlug, + toTitleCase, + toSentenceCase, + toDotCase, + toPathCase, + type CaseResult, +} from './caseConverter'; + +const SAMPLES = [ + 'user_profile_data', + 'APIResponseHandler', + 'fast-furious-tokyo-drift', + 'Café & Restaurant', + 'document.body.style', +]; + +const BATCH_TARGETS = [ + { label: 'camelCase', fn: (w: string[]) => toCamelCase(w) }, + { label: 'PascalCase', fn: (w: string[]) => toPascalCase(w) }, + { label: 'snake_case', fn: (w: string[]) => toSnakeCase(w) }, + { label: 'CONSTANT_CASE', fn: (w: string[]) => toConstantCase(w) }, + { label: 'kebab-case', fn: (w: string[]) => toKebabCase(w) }, + { label: 'URL Slug', fn: (_w: string[], raw: string) => toSlug(raw) }, + { label: 'Title Case', fn: (w: string[]) => toTitleCase(w) }, + { label: 'Sentence case', fn: (w: string[]) => toSentenceCase(w) }, + { label: 'dot.case', fn: (w: string[]) => toDotCase(w) }, + { label: 'path/case', fn: (w: string[]) => toPathCase(w) }, + { label: 'lowercase', fn: (_w: string[], raw: string) => raw.toLowerCase() }, + { label: 'UPPERCASE', fn: (_w: string[], raw: string) => raw.toUpperCase() }, +]; + +export default function CaseConverterTool() { + const [urlText, setUrlText] = useUrlState('t', 'hello_world_example', String, String); + const [text, setTextInternal] = useState(urlText); + const [isBatch, setIsBatch] = useState(false); + const [batchTarget, setBatchTarget] = useState('camelCase'); + + const setText = (val: string) => { + setTextInternal(val); + setUrlText(val); + }; + + useEffect(() => { + setTextInternal(urlText); + }, [urlText]); + + const detected = useMemo(() => detectCasing(text), [text]); + const results: CaseResult[] = useMemo(() => convertAllCases(text), [text]); + + const batchOutput = useMemo(() => { + if (!text) return ''; + const targetObj = BATCH_TARGETS.find((t) => t.label === batchTarget) || BATCH_TARGETS[0]; + return text + .split('\n') + .map((line) => { + const words = extractWords(line); + return targetObj.fn(words, line); + }) + .join('\n'); + }, [text, batchTarget]); + + return ( + + + {/* Controls row */} + + {/* Quick samples */} + + Examples: + {SAMPLES.map((s) => ( + setText(s)} + style={{ + padding: '4px 8px', + borderRadius: 6, + background: 'var(--surface-nav-item)', + border: '1px solid var(--line)', + fontSize: 12, + fontFamily: 'var(--font-mono)', + color: 'var(--ink-soft)', + cursor: 'pointer', + }} + > + {s} + + ))} + + + {/* Mode Switcher */} + + setIsBatch(false)} + style={{ + padding: '6px 12px', + borderRadius: 8, + fontSize: 12, + fontWeight: 600, + background: !isBatch ? 'rgba(139, 92, 246, 0.18)' : 'var(--surface-nav-item)', + border: `1px solid ${!isBatch ? 'rgba(139, 92, 246, 0.4)' : 'var(--line)'}`, + color: 'var(--ink)', + cursor: 'pointer', + }} + > + All Formats + + setIsBatch(true)} + style={{ + padding: '6px 12px', + borderRadius: 8, + fontSize: 12, + fontWeight: 600, + background: isBatch ? 'rgba(139, 92, 246, 0.18)' : 'var(--surface-nav-item)', + border: `1px solid ${isBatch ? 'rgba(139, 92, 246, 0.4)' : 'var(--line)'}`, + color: 'var(--ink)', + cursor: 'pointer', + }} + > + Batch Multi-Line + + + + + {/* Input Textarea */} + + setText(e.target.value)} + placeholder="Type or paste text to convert..." + rows={isBatch ? 5 : 3} + aria-label="Text input to convert" + style={{ + width: '100%', + padding: '12px 14px', + borderRadius: 12, + border: '1px solid var(--line)', + background: 'var(--surface-input)', + fontSize: 14, + fontFamily: 'var(--font-mono)', + color: 'var(--ink)', + outline: 'none', + resize: 'vertical', + }} + /> + + + + Detected: {detected} · {text.length} chars + + {text && ( + setText('')} + style={{ + background: 'transparent', + border: 'none', + color: 'var(--ink-mute)', + cursor: 'pointer', + fontSize: 11, + textDecoration: 'underline', + }} + > + Clear + + )} + + + + {/* Single Mode: All Formats Grid */} + {!isBatch ? ( + + {results.map((res) => ( + + + {res.name} + + + + {res.value || —} + + + ))} + + ) : ( + /* Batch Multi-Line Mode */ + + + Convert all lines to: + + {BATCH_TARGETS.map((t) => ( + setBatchTarget(t.label)} + style={{ + padding: '4px 10px', + borderRadius: 6, + fontSize: 12, + fontWeight: 500, + background: batchTarget === t.label ? 'rgba(139, 92, 246, 0.2)' : 'var(--surface-button-off)', + border: `1px solid ${batchTarget === t.label ? 'rgba(139, 92, 246, 0.4)' : 'var(--line)'}`, + color: 'var(--ink)', + cursor: 'pointer', + }} + > + {t.label} + + ))} + + + + + + + + + + + )} + + {/* Action bar */} + + setText('hello_world_example')} variant="soft"> + Reset Sample + + + + + ); +} diff --git a/src/tools/case-converter/caseConverter.test.ts b/src/tools/case-converter/caseConverter.test.ts new file mode 100644 index 0000000..b9da70b --- /dev/null +++ b/src/tools/case-converter/caseConverter.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import { + extractWords, + toCamelCase, + toPascalCase, + toSnakeCase, + toConstantCase, + toKebabCase, + toTitleCase, + toSentenceCase, + toDotCase, + toPathCase, + toSlug, + detectCasing, + convertAllCases, +} from './caseConverter'; + +describe('caseConverter logic', () => { + it('extracts words from diverse input casing conventions', () => { + expect(extractWords('helloWorld')).toEqual(['hello', 'World']); + expect(extractWords('HelloWorldTest')).toEqual(['Hello', 'World', 'Test']); + expect(extractWords('hello_world_test')).toEqual(['hello', 'world', 'test']); + expect(extractWords('HELLO_WORLD_TEST')).toEqual(['HELLO', 'WORLD', 'TEST']); + expect(extractWords('hello-world-test')).toEqual(['hello', 'world', 'test']); + expect(extractWords('hello.world.test')).toEqual(['hello', 'world', 'test']); + expect(extractWords('hello/world/test')).toEqual(['hello', 'world', 'test']); + expect(extractWords('XMLHttpRequest')).toEqual(['XML', 'Http', 'Request']); + }); + + it('handles empty and whitespace input', () => { + expect(extractWords('')).toEqual([]); + expect(extractWords(' ')).toEqual([]); + expect(toCamelCase([])).toBe(''); + expect(toSentenceCase([])).toBe(''); + expect(toSlug('')).toBe(''); + }); + + it('converts to camelCase', () => { + expect(toCamelCase(['hello', 'world'])).toBe('helloWorld'); + expect(toCamelCase(['user', 'profile', 'data'])).toBe('userProfileData'); + }); + + it('converts to PascalCase', () => { + expect(toPascalCase(['hello', 'world'])).toBe('HelloWorld'); + }); + + it('converts to snake_case and CONSTANT_CASE', () => { + expect(toSnakeCase(['Hello', 'World'])).toBe('hello_world'); + expect(toConstantCase(['Hello', 'World'])).toBe('HELLO_WORLD'); + }); + + it('converts to kebab-case, dot.case, and path/case', () => { + expect(toKebabCase(['hello', 'world'])).toBe('hello-world'); + expect(toDotCase(['hello', 'world'])).toBe('hello.world'); + expect(toPathCase(['hello', 'world'])).toBe('hello/world'); + }); + + it('converts to Title Case and Sentence case', () => { + expect(toTitleCase(['hello', 'world', 'again'])).toBe('Hello World Again'); + expect(toSentenceCase(['hello', 'world', 'again'])).toBe('Hello world again'); + }); + + it('generates clean URL slugs stripping unicode diacritics', () => { + expect(toSlug('Café & Restaurant — 2026!')).toBe('cafe-restaurant-2026'); + expect(toSlug('Crème Brûlée at Noël')).toBe('creme-brulee-at-noel'); + expect(toSlug('Niño Español')).toBe('nino-espanol'); + expect(toSlug('---Hello World---')).toBe('hello-world'); + }); + + it('detects common casing formats accurately', () => { + expect(detectCasing('camelCase')).toBe('camelCase'); + expect(detectCasing('PascalCase')).toBe('PascalCase'); + expect(detectCasing('snake_case')).toBe('snake_case'); + expect(detectCasing('CONSTANT_CASE')).toBe('CONSTANT_CASE'); + expect(detectCasing('kebab-case')).toBe('kebab-case'); + expect(detectCasing('dot.case')).toBe('dot.case'); + expect(detectCasing('path/case')).toBe('path/case'); + expect(detectCasing('lowercase')).toBe('lowercase'); + expect(detectCasing('UPPERCASE')).toBe('UPPERCASE'); + expect(detectCasing('Camel')).toBe('Capitalized'); + expect(detectCasing('Café')).toBe('Capitalized'); + expect(detectCasing('café')).toBe('lowercase'); + expect(detectCasing('CAFÉ')).toBe('UPPERCASE'); + expect(detectCasing('Plain english text here')).toBe('Natural text'); + expect(detectCasing('')).toBe('Unknown'); + }); + + it('convertAllCases returns all 12 formats', () => { + const results = convertAllCases('user_account_setting'); + expect(results).toHaveLength(12); + + const map = Object.fromEntries(results.map((r) => [r.id, r.value])); + expect(map.camel).toBe('userAccountSetting'); + expect(map.pascal).toBe('UserAccountSetting'); + expect(map.snake).toBe('user_account_setting'); + expect(map.constant).toBe('USER_ACCOUNT_SETTING'); + expect(map.kebab).toBe('user-account-setting'); + expect(map.slug).toBe('user-account-setting'); + expect(map.title).toBe('User Account Setting'); + expect(map.sentence).toBe('User account setting'); + expect(map.dot).toBe('user.account.setting'); + expect(map.path).toBe('user/account/setting'); + }); + + it('preserves accented and international letters across case conversions', () => { + const cafeWords = extractWords('caféLatte'); + expect(cafeWords).toEqual(['café', 'Latte']); + expect(toSnakeCase(cafeWords)).toBe('café_latte'); + expect(toPascalCase(cafeWords)).toBe('CaféLatte'); + expect(toConstantCase(cafeWords)).toBe('CAFÉ_LATTE'); + expect(toKebabCase(cafeWords)).toBe('café-latte'); + expect(toTitleCase(cafeWords)).toBe('Café Latte'); + expect(toSentenceCase(cafeWords)).toBe('Café latte'); + expect(toDotCase(cafeWords)).toBe('café.latte'); + expect(toPathCase(cafeWords)).toBe('café/latte'); + + const multiAccented = extractWords('Café & Restaurant'); + expect(multiAccented).toEqual(['Café', 'Restaurant']); + expect(toCamelCase(multiAccented)).toBe('caféRestaurant'); + expect(toSnakeCase(multiAccented)).toBe('café_restaurant'); + }); + + it('handles words written entirely in non-Latin scripts', () => { + // Cyrillic + const cyrillicWords = extractWords('приветМир'); + expect(cyrillicWords).toEqual(['привет', 'Мир']); + expect(toSnakeCase(cyrillicWords)).toBe('привет_мир'); + expect(toCamelCase(extractWords('привет_мир'))).toBe('приветМир'); + expect(toPascalCase(cyrillicWords)).toBe('ПриветМир'); + expect(toConstantCase(cyrillicWords)).toBe('ПРИВЕТ_МИР'); + expect(toKebabCase(cyrillicWords)).toBe('привет-мир'); + expect(toSlug('Привет Мир')).toBe('привет-мир'); + + // Greek + const greekWords = extractWords('γειαΣου'); + expect(greekWords).toEqual(['γεια', 'Σου']); + expect(toSnakeCase(greekWords)).toBe('γεια_σου'); + expect(toPascalCase(greekWords)).toBe('ΓειαΣου'); + }); + + it('detects casing correctly for international text', () => { + expect(detectCasing('caféLatte')).toBe('camelCase'); + expect(detectCasing('CaféLatte')).toBe('PascalCase'); + expect(detectCasing('café_latte')).toBe('snake_case'); + expect(detectCasing('CAFÉ_LATTE')).toBe('CONSTANT_CASE'); + expect(detectCasing('приветМир')).toBe('camelCase'); + expect(detectCasing('привет_мир')).toBe('snake_case'); + }); +}); diff --git a/src/tools/case-converter/caseConverter.ts b/src/tools/case-converter/caseConverter.ts new file mode 100644 index 0000000..7b77847 --- /dev/null +++ b/src/tools/case-converter/caseConverter.ts @@ -0,0 +1,130 @@ +export interface CaseResult { + id: string; + name: string; + example: string; + value: string; +} + +/** + * Splits any string into constituent lowercase words based on standard casing conventions: + * camelCase, PascalCase, snake_case, kebab-case, dots, slashes, spaces. + */ +export function extractWords(input: string): string[] { + if (!input || !input.trim()) return []; + + // 1. Separate camelCase/PascalCase boundaries (including Unicode letters) + // e.g. "caféLatte" -> "café Latte", "приветМир" -> "привет Мир", "XMLHttpRequest" -> "XML Http Request" + const separated = input + .replace(/([\p{Ll}\p{N}])([\p{Lu}\p{Lt}])/gu, '$1 $2') + .replace(/([\p{Lu}\p{Lt}]+)([\p{Lu}\p{Lt}][\p{Ll}])/gu, '$1 $2'); + + // 2. Extract words composed of Unicode letters and numbers + const matches = separated.match(/[\p{L}\p{N}]+/gu); + return matches || []; +} + +export function toCamelCase(words: string[]): string { + if (words.length === 0) return ''; + return words + .map((w, i) => { + const lower = w.toLowerCase(); + return i === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1); + }) + .join(''); +} + +export function toPascalCase(words: string[]): string { + return words + .map((w) => { + const lower = w.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); + }) + .join(''); +} + +export function toSnakeCase(words: string[]): string { + return words.map((w) => w.toLowerCase()).join('_'); +} + +export function toConstantCase(words: string[]): string { + return words.map((w) => w.toUpperCase()).join('_'); +} + +export function toKebabCase(words: string[]): string { + return words.map((w) => w.toLowerCase()).join('-'); +} + +export function toTitleCase(words: string[]): string { + return words + .map((w) => { + const lower = w.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); + }) + .join(' '); +} + +export function toSentenceCase(words: string[]): string { + if (words.length === 0) return ''; + const full = words.map((w) => w.toLowerCase()).join(' '); + return full.charAt(0).toUpperCase() + full.slice(1); +} + +export function toDotCase(words: string[]): string { + return words.map((w) => w.toLowerCase()).join('.'); +} + +export function toPathCase(words: string[]): string { + return words.map((w) => w.toLowerCase()).join('/'); +} + +export function toSlug(input: string): string { + if (!input) return ''; + return input + .normalize('NFD') // normalize unicode + .replace(/[\u0300-\u036f]/g, '') // strip Latin diacritics (é -> e, ñ -> n) + .toLowerCase() + .trim() + .replace(/[^\p{L}\p{N}\s\-_]/gu, '') // preserve letters & digits across scripts, strip punctuation + .replace(/[\s_]+/g, '-') // spaces and underscores to hyphens + .replace(/-+/g, '-') // collapse consecutive hyphens + .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens +} + +export function detectCasing(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return 'Unknown'; + + if (/^[\p{Ll}]+(?:[\p{Lu}\p{Lt}][\p{Ll}\p{N}]*)+$/u.test(trimmed)) return 'camelCase'; + if (/^[\p{Lu}\p{Lt}][\p{Ll}\p{N}]+(?:[\p{Lu}\p{Lt}][\p{Ll}\p{N}]*)+$/u.test(trimmed)) return 'PascalCase'; + if (/^[\p{Ll}\p{N}]+(?:_[\p{Ll}\p{N}]+)+$/u.test(trimmed)) return 'snake_case'; + if (/^[\p{Lu}\p{Lt}\p{N}]+(?:_[\p{Lu}\p{Lt}\p{N}]+)+$/u.test(trimmed)) return 'CONSTANT_CASE'; + if (/^[\p{Ll}\p{N}]+(?:-[\p{Ll}\p{N}]+)+$/u.test(trimmed)) return 'kebab-case'; + if (/^[\p{Ll}\p{N}]+(?:\.[\p{Ll}\p{N}]+)+$/u.test(trimmed)) return 'dot.case'; + if (/^[\p{Ll}\p{N}]+(?:\/[\p{Ll}\p{N}]+)+$/u.test(trimmed)) return 'path/case'; + + // Explicit single-word formats + if (/^[\p{Lu}\p{Lt}][\p{Ll}\p{N}]*$/u.test(trimmed)) return 'Capitalized'; + if (/^[\p{Ll}\p{N}]+$/u.test(trimmed)) return 'lowercase'; + if (/^[\p{Lu}\p{Lt}\p{N}]+$/u.test(trimmed)) return 'UPPERCASE'; + + return 'Natural text'; +} + +export function convertAllCases(input: string): CaseResult[] { + const words = extractWords(input); + + return [ + { id: 'camel', name: 'camelCase', example: 'myVariableName', value: toCamelCase(words) }, + { id: 'pascal', name: 'PascalCase', example: 'MyVariableName', value: toPascalCase(words) }, + { id: 'snake', name: 'snake_case', example: 'my_variable_name', value: toSnakeCase(words) }, + { id: 'constant', name: 'CONSTANT_CASE', example: 'MY_VARIABLE_NAME', value: toConstantCase(words) }, + { id: 'kebab', name: 'kebab-case', example: 'my-variable-name', value: toKebabCase(words) }, + { id: 'slug', name: 'URL Slug', example: 'my-article-title-2026', value: toSlug(input) }, + { id: 'title', name: 'Title Case', example: 'My Variable Name', value: toTitleCase(words) }, + { id: 'sentence', name: 'Sentence case', example: 'My variable name', value: toSentenceCase(words) }, + { id: 'dot', name: 'dot.case', example: 'my.variable.name', value: toDotCase(words) }, + { id: 'path', name: 'path/case', example: 'my/variable/name', value: toPathCase(words) }, + { id: 'lower', name: 'lowercase', example: 'my variable name', value: input.toLowerCase() }, + { id: 'upper', name: 'UPPERCASE', example: 'MY VARIABLE NAME', value: input.toUpperCase() }, + ]; +} diff --git a/src/tools/case-converter/caseConverterTool.test.tsx b/src/tools/case-converter/caseConverterTool.test.tsx new file mode 100644 index 0000000..79c2f34 --- /dev/null +++ b/src/tools/case-converter/caseConverterTool.test.tsx @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { ToastProvider } from '../../components/ui/Toast'; +import CaseConverterTool from './CaseConverterTool'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe('CaseConverterTool Component', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + const renderTool = (initialPath = '/tools/case-converter') => { + act(() => { + root.render( + + + + } /> + + + + ); + }); + }; + + it('renders the initial layout with default input and format cards', () => { + renderTool(); + + expect(container.textContent).toContain('Text Case Converter & Slug'); + expect(container.textContent).toContain('camelCase'); + expect(container.textContent).toContain('snake_case'); + expect(container.textContent).toContain('kebab-case'); + expect(container.textContent).toContain('URL Slug'); + + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + expect(textarea.value).toBe('hello_world_example'); + }); + + it('updates converted outputs dynamically when input value changes', () => { + renderTool(); + + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + + act(() => { + const nativeTextareaValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + 'value' + )?.set; + nativeTextareaValueSetter?.call(textarea, 'quickBrownFox'); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true })); + }); + + expect(container.textContent).toContain('quick_brown_fox'); + expect(container.textContent).toContain('quick-brown-fox'); + expect(container.textContent).toContain('QUICK_BROWN_FOX'); + }); + + it('loads sample texts when sample buttons are clicked', () => { + renderTool(); + + const sampleButtons = Array.from(container.querySelectorAll('button')).filter((b) => + b.textContent?.includes('APIResponseHandler') + ); + expect(sampleButtons.length).toBeGreaterThan(0); + + act(() => { + sampleButtons[0].click(); + }); + + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; + expect(textarea.value).toBe('APIResponseHandler'); + expect(container.textContent).toContain('api_response_handler'); + }); + + it('switches to Batch multi-line mode and transforms text', () => { + renderTool(); + + const batchButton = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('Batch Multi-Line') + ); + expect(batchButton).toBeDefined(); + + act(() => { + batchButton?.click(); + }); + + expect(container.textContent).toContain('Convert all lines to:'); + + const textareas = container.querySelectorAll('textarea'); + expect(textareas.length).toBeGreaterThanOrEqual(2); + + const inputTextarea = textareas[0]; + act(() => { + const nativeTextareaValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + 'value' + )?.set; + nativeTextareaValueSetter?.call(inputTextarea, 'first_item\nsecond_item\nthird_item'); + inputTextarea.dispatchEvent(new Event('input', { bubbles: true })); + inputTextarea.dispatchEvent(new Event('change', { bubbles: true })); + }); + + const outputTextarea = textareas[1]; + expect(outputTextarea.value).toContain('firstItem'); + expect(outputTextarea.value).toContain('secondItem'); + expect(outputTextarea.value).toContain('thirdItem'); + }); + + it('displays detected casing badge for input', () => { + renderTool(); + expect(container.textContent).toContain('Detected: snake_case'); + }); +}); diff --git a/src/tools/registry.tsx b/src/tools/registry.tsx index c7658b2..43309c6 100644 --- a/src/tools/registry.tsx +++ b/src/tools/registry.tsx @@ -16,7 +16,8 @@ import { Code2, Globe2, QrCode, - FileText + FileText, + Type, } from 'lucide-react'; export interface ToolMeta { @@ -183,6 +184,15 @@ export const tools: ToolMeta[] = [ tint: 'linear-gradient(135deg, #bfdbfe, #c4b5fd)', Component: lazy(() => import('./markdown/MarkdownTool')), }, + { + slug: 'case-converter', + title: 'Text Case Converter & Slug', + short: 'camel, snake, kebab & slugs', + description: 'Transform text between camelCase, PascalCase, snake_case, kebab-case, and URL slugs.', + icon: , + tint: 'linear-gradient(135deg, #fed7aa, #f472b6)', + Component: lazy(() => import('./case-converter/CaseConverterTool')), + }, ]; export const toolBySlug = (slug: string): ToolMeta | undefined =>