diff --git a/src/config/routes.config.ts b/src/config/routes.config.ts index 81a36c6..e4c250a 100644 --- a/src/config/routes.config.ts +++ b/src/config/routes.config.ts @@ -17,6 +17,7 @@ export const ROUTES = { 'whats-my-ip': '/whats-my-ip', 'ip-location': '/ip-location', 'json-formatter': '/json', + 'json-escape': '/json-escape', 'xml-formatter': '/xml', } as const diff --git a/src/config/tools.config.tsx b/src/config/tools.config.tsx index 8bfd856..ba5f0ba 100644 --- a/src/config/tools.config.tsx +++ b/src/config/tools.config.tsx @@ -14,6 +14,7 @@ import { MapPin, Braces, Code2, + Quote, } from 'lucide-react' import { ROUTES } from './routes.config' import { CATEGORIES, type Tool } from '@/types/tool.types' @@ -257,6 +258,16 @@ export const tools: Tool[] = [ }, ], }, + { + title: 'JSON Escape', + href: ROUTES['json-escape'], + description: 'Escape or unescape JSON special characters in text', + icon: Quote, + categories: [CATEGORIES.DEVELOPMENT], + searchTags: ['json', 'escape', 'unescape', 'quotes', 'string', 'format'], + component: React.lazy(() => import('@/tools/JsonEscape')), + online: false, + }, { title: 'XML Formatter', href: ROUTES['xml-formatter'], diff --git a/src/stores/index.ts b/src/stores/index.ts index bae9e02..958c40c 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1,3 +1,4 @@ export { useBase64Store } from './base64.store' export { useURLStore } from './url.store' export { useJSONFormatterStore } from './json-formatter.store' +export { useJsonEscapeStore } from './json-escape.store' diff --git a/src/stores/json-escape.store.ts b/src/stores/json-escape.store.ts new file mode 100644 index 0000000..9042822 --- /dev/null +++ b/src/stores/json-escape.store.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand' + +/** + * JSON Escape/Unescape store + * Simple store for managing the text area input + */ +interface JsonEscapeStore { + input: string + setInput: (value: string) => void + reset: () => void +} + +export const useJsonEscapeStore = create((set) => ({ + input: '', + setInput: (value) => set({ input: value }), + reset: () => set({ input: '' }), +})) diff --git a/src/tools/JsonEscape.test.tsx b/src/tools/JsonEscape.test.tsx new file mode 100644 index 0000000..40d1354 --- /dev/null +++ b/src/tools/JsonEscape.test.tsx @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import JsonEscape from './JsonEscape' +import { useJsonEscapeStore } from '@/stores' + +// Reset store state before each test +beforeEach(() => { + useJsonEscapeStore.getState().reset() +}) + +describe('JsonEscape', () => { + it('renders with empty textarea and three buttons', () => { + render() + expect(screen.getByPlaceholderText(/paste your text here/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^escape$/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /unescape/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /clear/i })).toBeInTheDocument() + }) + + describe('Escape functionality', () => { + it('escapes double quotes', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + act(() => { + useJsonEscapeStore.getState().setInput('Hello "World"') + }) + }) + await user.click(screen.getByRole('button', { name: /^escape$/i })) + + expect(input).toHaveValue('Hello \\"World\\"') + }) + + it('escapes backslashes', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + act(() => { + useJsonEscapeStore.getState().setInput('C:\\Users\\Test') + }) + }) + await user.click(screen.getByRole('button', { name: /^escape$/i })) + + expect(input).toHaveValue('C:\\\\Users\\\\Test') + }) + + it('escapes newlines', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + // Directly set value with newline + act(() => { + useJsonEscapeStore.getState().setInput('Line1\nLine2') + }) + await user.click(screen.getByRole('button', { name: /^escape$/i })) + + expect(input).toHaveValue('Line1\\nLine2') + }) + + it('escapes tabs', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + useJsonEscapeStore.getState().setInput('Column1\tColumn2') + }) + await user.click(screen.getByRole('button', { name: /^escape$/i })) + + expect(input).toHaveValue('Column1\\tColumn2') + }) + + it('escapes carriage returns', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + useJsonEscapeStore.getState().setInput('Line1\rLine2') + }) + await user.click(screen.getByRole('button', { name: /^escape$/i })) + + expect(input).toHaveValue('Line1\\rLine2') + }) + + it('escapes multiple special characters at once', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + useJsonEscapeStore.getState().setInput('Hello "World"\nNew line\tTab') + }) + await user.click(screen.getByRole('button', { name: /^escape$/i })) + + expect(input).toHaveValue('Hello \\"World\\"\\nNew line\\tTab') + }) + + it('handles empty input when escaping', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + await user.click(screen.getByRole('button', { name: /^escape$/i })) + + expect(input).toHaveValue('') + }) + }) + + describe('Unescape functionality', () => { + it('unescapes double quotes', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + useJsonEscapeStore.getState().setInput('Hello \\"World\\"') + }) + await user.click(screen.getByRole('button', { name: /unescape/i })) + + expect(input).toHaveValue('Hello "World"') + }) + + it('unescapes newlines', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + useJsonEscapeStore.getState().setInput('Line1\\nLine2') + }) + await user.click(screen.getByRole('button', { name: /unescape/i })) + + expect(input).toHaveValue('Line1\nLine2') + }) + + it('unescapes tabs', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + useJsonEscapeStore.getState().setInput('Column1\\tColumn2') + }) + await user.click(screen.getByRole('button', { name: /unescape/i })) + + expect(input).toHaveValue('Column1\tColumn2') + }) + + it('unescapes backslashes', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + useJsonEscapeStore.getState().setInput('C:\\\\Users\\\\Test') + }) + await user.click(screen.getByRole('button', { name: /unescape/i })) + + expect(input).toHaveValue('C:\\Users\\Test') + }) + + it('unescapes multiple special characters at once', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + act(() => { + useJsonEscapeStore.getState().setInput('Hello \\"World\\"\\nNew line\\tTab') + }) + await user.click(screen.getByRole('button', { name: /unescape/i })) + + expect(input).toHaveValue('Hello "World"\nNew line\tTab') + }) + + it('handles empty input when unescaping', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + await user.click(screen.getByRole('button', { name: /unescape/i })) + + expect(input).toHaveValue('') + }) + }) + + describe('Clear functionality', () => { + it('clears the textarea when Clear button is clicked', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + await user.type(input, 'Some text') + expect(input).toHaveValue('Some text') + + await user.click(screen.getByRole('button', { name: /clear/i })) + + expect(input).toHaveValue('') + }) + + it('clears after escape operation', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + await user.type(input, 'Hello "World"') + await user.click(screen.getByRole('button', { name: /^escape$/i })) + expect(input).toHaveValue('Hello \\"World\\"') + + await user.click(screen.getByRole('button', { name: /clear/i })) + + expect(input).toHaveValue('') + }) + }) + + describe('Round-trip operations', () => { + it('escape then unescape returns original text', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + const originalText = 'Hello "World"\nNew line\tTab' + act(() => { + useJsonEscapeStore.getState().setInput(originalText) + }) + + // Escape + await user.click(screen.getByRole('button', { name: /^escape$/i })) + const escapedValue = (input as HTMLTextAreaElement).value + expect(escapedValue).toBe('Hello \\"World\\"\\nNew line\\tTab') + + // Unescape + await user.click(screen.getByRole('button', { name: /unescape/i })) + expect(input).toHaveValue(originalText) + }) + }) + + describe('Store integration', () => { + it('updates store when typing in textarea', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + await user.type(input, 'test') + + expect(useJsonEscapeStore.getState().input).toBe('test') + }) + + it('resets store when Clear is clicked', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText(/paste your text here/i) + await user.type(input, 'test') + expect(useJsonEscapeStore.getState().input).toBe('test') + + await user.click(screen.getByRole('button', { name: /clear/i })) + + expect(useJsonEscapeStore.getState().input).toBe('') + }) + }) +}) diff --git a/src/tools/JsonEscape.tsx b/src/tools/JsonEscape.tsx new file mode 100644 index 0000000..265a564 --- /dev/null +++ b/src/tools/JsonEscape.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react' +import TextArea from '@/components/TextArea' +import { Button } from '@/components/ui/button' +import { useJsonEscapeStore } from '@/stores' + +/** + * Escapes special JSON characters in a string + */ +function escapeJson(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') +} + +/** + * Unescapes JSON escaped characters in a string + */ +function unescapeJson(str: string): string { + try { + // Use JSON.parse with a wrapper to handle the unescaping + return JSON.parse(`"${str}"`) as string + } catch { + // If JSON.parse fails, try manual replacement + return str + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') + } +} + +function JsonEscape() { + const { input, setInput, reset } = useJsonEscapeStore() + const textareaRef = useRef(null) + + const handleEscape = () => { + const escaped = escapeJson(input) + setInput(escaped) + } + + const handleUnescape = () => { + const unescaped = unescapeJson(input) + setInput(unescaped) + } + + const handleClear = () => { + reset() + } + + useEffect(() => { + textareaRef.current?.focus() + }, []) + + return ( +
+