feat(json-viewer): add auto-unescape functionality for escaped JSON strings

- Add auto-unescape toggle to handle escaped JSON strings like '{\"key\":\"value\"}'
- Implement smart unescaping logic that handles quotes, backslashes, and common escape sequences
- Support both quoted and unquoted escaped JSON formats
- Add real-time validation that works with auto-unescape toggle
- Improve UI layout with wider label for auto-unescape option
- Add comprehensive unit tests (15 test cases) covering all functionality
- Add E2E tests (6 test scenarios) for complete user workflow testing
- Update placeholder text to guide users about the new feature
- Add test-id for better E2E test reliability

This enhancement allows developers to easily prettify JSON from logs, API responses,
and other sources where JSON is commonly escaped in strings.
This commit is contained in:
Qi Yu 2025-08-29 14:29:48 +08:00
parent 07eea0f484
commit a831874e60
4 changed files with 271 additions and 6 deletions

View File

@ -0,0 +1,87 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - JSON prettify and format', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/json-prettify');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('JSON prettify and format - IT Tools');
});
test('prettifies and formats valid JSON', async ({ page }) => {
await page.getByTestId('json-prettify-input').fill('{"b":2,"a":1,"c":{"z":3,"y":2}}');
const prettifiedJson = await page.getByTestId('area-content').innerText();
expect(prettifiedJson.trim()).toContain('"a": 1');
expect(prettifiedJson.trim()).toContain('"b": 2');
// Keys should be sorted alphabetically
expect(prettifiedJson.indexOf('"a"')).toBeLessThan(prettifiedJson.indexOf('"b"'));
});
test('handles sort keys toggle', async ({ page }) => {
await page.getByTestId('json-prettify-input').fill('{"b":2,"a":1}');
// Disable sort keys
await page.locator('label:has-text("Sort keys")').locator('input[type="checkbox"]').click();
const unsortedJson = await page.getByTestId('area-content').innerText();
// Keys should maintain original order when sorting is disabled
expect(unsortedJson.indexOf('"b"')).toBeLessThan(unsortedJson.indexOf('"a"'));
});
test('handles custom indent size', async ({ page }) => {
await page.getByTestId('json-prettify-input').fill('{"a":1}');
// Change indent size to 2
await page.locator('label:has-text("Indent size")').locator('input[type="number"]').fill('2');
const formattedJson = await page.getByTestId('area-content').innerText();
// Should use 2-space indentation
expect(formattedJson).toContain(' "a": 1');
});
test('auto-unescape functionality works with escaped JSON', async ({ page }) => {
const escapedJson = '"{\\\"id\\\":\\\"123\\\",\\\"name\\\":\\\"test\\\"}"';
await page.getByTestId('json-prettify-input').fill(escapedJson);
// Enable auto-unescape
await page.locator('label:has-text("Auto-unescape")').locator('input[type="checkbox"]').click();
const unescapedJson = await page.getByTestId('area-content').innerText();
expect(unescapedJson).toContain('"id": "123"');
expect(unescapedJson).toContain('"name": "test"');
expect(unescapedJson).not.toContain('\\"');
});
test('auto-unescape toggle affects validation', async ({ page }) => {
const escapedJson = '"{\\\"valid\\\":\\\"json\\\"}"';
// First, paste escaped JSON without auto-unescape (should show validation error)
await page.getByTestId('json-prettify-input').fill(escapedJson);
// Should show validation error
await expect(page.locator('text=Provided JSON is not valid.')).toBeVisible();
// Enable auto-unescape
await page.locator('label:has-text("Auto-unescape")').locator('input[type="checkbox"]').click();
// Validation error should disappear
await expect(page.locator('text=Provided JSON is not valid.')).not.toBeVisible();
// Output should be properly formatted
const formattedJson = await page.getByTestId('area-content').innerText();
expect(formattedJson).toContain('"valid": "json"');
});
test('displays helpful placeholder text', async ({ page }) => {
const textarea = page.getByTestId('json-prettify-input');
await expect(textarea).toHaveAttribute('placeholder', /auto-unescape.*escaped json/i);
});
});

View File

@ -11,25 +11,61 @@ const inputElement = ref<HTMLElement>();
const rawJson = useStorage('json-prettify:raw-json', '{"hello": "world", "foo": "bar"}'); const rawJson = useStorage('json-prettify:raw-json', '{"hello": "world", "foo": "bar"}');
const indentSize = useStorage('json-prettify:indent-size', 3); const indentSize = useStorage('json-prettify:indent-size', 3);
const sortKeys = useStorage('json-prettify:sort-keys', true); const sortKeys = useStorage('json-prettify:sort-keys', true);
const cleanJson = computed(() => withDefaultOnError(() => formatJson({ rawJson, indentSize, sortKeys }), '')); const autoUnescape = useStorage('json-prettify:auto-unescape', false);
const cleanJson = computed(() => withDefaultOnError(() => formatJson({ rawJson, indentSize, sortKeys, autoUnescape }), ''));
const rawJsonValidation = useValidation({ const rawJsonValidation = useValidation({
source: rawJson, source: rawJson,
rules: [ rules: [
{ {
validator: v => v === '' || JSON5.parse(v), validator: (v: string) => {
if (v === '') {
return true;
}
try {
let jsonString = v;
if (autoUnescape.value) {
// Apply the same unescaping logic for validation
jsonString = jsonString.trim();
if ((jsonString.startsWith('"') && jsonString.endsWith('"'))
|| (jsonString.startsWith('\'') && jsonString.endsWith('\''))) {
jsonString = jsonString.slice(1, -1);
}
jsonString = jsonString
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\f/g, '\f')
.replace(/\\b/g, '\b')
.replace(/\\\//g, '/');
}
JSON5.parse(jsonString);
return true;
}
catch {
return false;
}
},
message: 'Provided JSON is not valid.', message: 'Provided JSON is not valid.',
}, },
], ],
watch: [autoUnescape],
}); });
</script> </script>
<template> <template>
<div style="flex: 0 0 100%"> <div style="flex: 0 0 100%">
<div style="margin: 0 auto; max-width: 600px" flex justify-center gap-3> <div style="margin: 0 auto; max-width: 700px" flex flex-wrap justify-center gap-3>
<n-form-item label="Sort keys :" label-placement="left" label-width="100"> <n-form-item label="Sort keys :" label-placement="left" label-width="100">
<n-switch v-model:value="sortKeys" /> <n-switch v-model:value="sortKeys" />
</n-form-item> </n-form-item>
<n-form-item label="Auto-unescape :" label-placement="left" label-width="130">
<n-switch v-model:value="autoUnescape" />
</n-form-item>
<n-form-item label="Indent size :" label-placement="left" label-width="100" :show-feedback="false"> <n-form-item label="Indent size :" label-placement="left" label-width="100" :show-feedback="false">
<n-input-number v-model:value="indentSize" min="0" max="10" style="width: 100px" /> <n-input-number v-model:value="indentSize" min="0" max="10" style="width: 100px" />
</n-form-item> </n-form-item>
@ -44,7 +80,7 @@ const rawJsonValidation = useValidation({
<c-input-text <c-input-text
ref="inputElement" ref="inputElement"
v-model:value="rawJson" v-model:value="rawJson"
placeholder="Paste your raw JSON here..." placeholder="Paste your raw JSON here... Enable 'Auto-unescape' for escaped JSON strings"
rows="20" rows="20"
multiline multiline
autocomplete="off" autocomplete="off"
@ -52,6 +88,7 @@ const rawJsonValidation = useValidation({
autocapitalize="off" autocapitalize="off"
spellcheck="false" spellcheck="false"
monospace monospace
test-id="json-prettify-input"
/> />
</n-form-item> </n-form-item>
<n-form-item label="Prettified version of your JSON"> <n-form-item label="Prettified version of your JSON">

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { sortObjectKeys } from './json.models'; import { ref } from 'vue';
import { formatJson, sortObjectKeys } from './json.models';
describe('json models', () => { describe('json models', () => {
describe('sortObjectKeys', () => { describe('sortObjectKeys', () => {
@ -13,4 +14,105 @@ describe('json models', () => {
); );
}); });
}); });
describe('formatJson', () => {
const testJson = '{"b": 2, "a": 1}';
const expectedSorted = '{\n "a": 1,\n "b": 2\n}';
const expectedUnsorted = '{\n "b": 2,\n "a": 1\n}';
it('formats JSON with default options (sorted keys, 3 spaces)', () => {
const result = formatJson({ rawJson: testJson });
expect(result).toBe(expectedSorted);
});
it('formats JSON without sorting keys when sortKeys is false', () => {
const result = formatJson({ rawJson: testJson, sortKeys: false });
expect(result).toBe(expectedUnsorted);
});
it('formats JSON with custom indent size', () => {
const result = formatJson({ rawJson: testJson, indentSize: 2 });
const expected = '{\n "a": 1,\n "b": 2\n}';
expect(result).toBe(expected);
});
it('works with reactive refs', () => {
const rawJsonRef = ref(testJson);
const sortKeysRef = ref(true);
const indentSizeRef = ref(3);
const result = formatJson({
rawJson: rawJsonRef,
sortKeys: sortKeysRef,
indentSize: indentSizeRef,
});
expect(result).toBe(expectedSorted);
});
describe('autoUnescape functionality', () => {
it('unescapes escaped JSON strings when autoUnescape is true', () => {
const escapedJson = '"{\\\"id\\\":\\\"123\\\",\\\"name\\\":\\\"test\\\"}"';
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
const expected = '{\n "id": "123",\n "name": "test"\n}';
expect(result).toBe(expected);
});
it('handles escaped JSON without outer quotes', () => {
const escapedJson = '{\\\"id\\\":\\\"123\\\",\\\"name\\\":\\\"test\\\"}';
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
const expected = '{\n "id": "123",\n "name": "test"\n}';
expect(result).toBe(expected);
});
it('unescapes various escape sequences', () => {
const escapedJson = '{\\\"text\\\":\\\"Hello\\\\\\\\World\\\",\\\"path\\\":\\\"/api\\\\/test\\\"}';
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
const expected = '{\n "path": "/api/test",\n "text": "Hello\\\\World"\n}';
expect(result).toBe(expected);
});
it('handles single-quoted outer strings', () => {
const escapedJson = '\'{\\\"id\\\":\\\"123\\\"}\'';
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
const expected = '{\n "id": "123"\n}';
expect(result).toBe(expected);
});
it('processes regular JSON normally when autoUnescape is false', () => {
const normalJson = '{"id":"123","name":"test"}';
const result = formatJson({ rawJson: normalJson, autoUnescape: false, indentSize: 2 });
const expected = '{\n "id": "123",\n "name": "test"\n}';
expect(result).toBe(expected);
});
it('handles malformed escaped JSON gracefully', () => {
const malformedJson = '"{\\\"incomplete';
// Should fall back to original string and fail parsing
expect(() => formatJson({ rawJson: malformedJson, autoUnescape: true })).toThrow();
});
it('works with complex nested objects', () => {
const complexEscaped = '"{\\\"users\\\":[{\\\"id\\\":\\\"1\\\",\\\"data\\\":{\\\"active\\\":true}}],\\\"meta\\\":{\\\"total\\\":1}}"';
const result = formatJson({ rawJson: complexEscaped, autoUnescape: true, indentSize: 2 });
const expected = '{\n "meta": {\n "total": 1\n },\n "users": [\n {\n "data": {\n "active": true\n },\n "id": "1"\n }\n ]\n}';
expect(result).toBe(expected);
});
it('works with reactive autoUnescape ref', () => {
const escapedJson = '"{\\\"test\\\":\\\"value\\\"}"';
const autoUnescapeRef = ref(true);
const result = formatJson({ rawJson: escapedJson, autoUnescape: autoUnescapeRef, indentSize: 2 });
const expected = '{\n "test": "value"\n}';
expect(result).toBe(expected);
});
});
it('handles empty string input', () => {
expect(() => formatJson({ rawJson: '' })).toThrow();
});
it('handles invalid JSON input', () => {
expect(() => formatJson({ rawJson: 'invalid json' })).toThrow();
});
});
}); });

View File

@ -20,16 +20,55 @@ function sortObjectKeys<T>(obj: T): T {
}, {} as Record<string, unknown>) as T; }, {} as Record<string, unknown>) as T;
} }
function unescapeJson(jsonString: string): string {
try {
// First, try to handle double-escaped scenarios
let result = jsonString.trim();
// If the string starts and ends with quotes, and contains escaped quotes inside,
// it might be a JSON string that needs to be unescaped
if ((result.startsWith('"') && result.endsWith('"'))
|| (result.startsWith('\'') && result.endsWith('\''))) {
// Remove outer quotes first
result = result.slice(1, -1);
}
// Handle common escape sequences
result = result
.replace(/\\"/g, '"') // Unescape quotes
.replace(/\\\\/g, '\\') // Unescape backslashes (do this after quotes!)
.replace(/\\n/g, '\n') // Unescape newlines
.replace(/\\r/g, '\r') // Unescape carriage returns
.replace(/\\t/g, '\t') // Unescape tabs
.replace(/\\f/g, '\f') // Unescape form feeds
.replace(/\\b/g, '\b') // Unescape backspaces
.replace(/\\\//g, '/'); // Unescape forward slashes
return result;
}
catch {
return jsonString;
}
}
function formatJson({ function formatJson({
rawJson, rawJson,
sortKeys = true, sortKeys = true,
indentSize = 3, indentSize = 3,
autoUnescape = false,
}: { }: {
rawJson: MaybeRef<string> rawJson: MaybeRef<string>
sortKeys?: MaybeRef<boolean> sortKeys?: MaybeRef<boolean>
indentSize?: MaybeRef<number> indentSize?: MaybeRef<number>
autoUnescape?: MaybeRef<boolean>
}) { }) {
const parsedObject = JSON5.parse(get(rawJson)); let jsonString = get(rawJson);
if (get(autoUnescape)) {
jsonString = unescapeJson(jsonString);
}
const parsedObject = JSON5.parse(jsonString);
return JSON.stringify(get(sortKeys) ? sortObjectKeys(parsedObject) : parsedObject, null, get(indentSize)); return JSON.stringify(get(sortKeys) ? sortObjectKeys(parsedObject) : parsedObject, null, get(indentSize));
} }