Skip to content

Commit a831874

Browse files
author
Qi Yu
committed
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.
1 parent 07eea0f commit a831874

File tree

4 files changed

+271
-6
lines changed

4 files changed

+271
-6
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test.describe('Tool - JSON prettify and format', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/json-prettify');
6+
});
7+
8+
test('Has correct title', async ({ page }) => {
9+
await expect(page).toHaveTitle('JSON prettify and format - IT Tools');
10+
});
11+
12+
test('prettifies and formats valid JSON', async ({ page }) => {
13+
await page.getByTestId('json-prettify-input').fill('{"b":2,"a":1,"c":{"z":3,"y":2}}');
14+
15+
const prettifiedJson = await page.getByTestId('area-content').innerText();
16+
17+
expect(prettifiedJson.trim()).toContain('"a": 1');
18+
expect(prettifiedJson.trim()).toContain('"b": 2');
19+
// Keys should be sorted alphabetically
20+
expect(prettifiedJson.indexOf('"a"')).toBeLessThan(prettifiedJson.indexOf('"b"'));
21+
});
22+
23+
test('handles sort keys toggle', async ({ page }) => {
24+
await page.getByTestId('json-prettify-input').fill('{"b":2,"a":1}');
25+
26+
// Disable sort keys
27+
await page.locator('label:has-text("Sort keys")').locator('input[type="checkbox"]').click();
28+
29+
const unsortedJson = await page.getByTestId('area-content').innerText();
30+
31+
// Keys should maintain original order when sorting is disabled
32+
expect(unsortedJson.indexOf('"b"')).toBeLessThan(unsortedJson.indexOf('"a"'));
33+
});
34+
35+
test('handles custom indent size', async ({ page }) => {
36+
await page.getByTestId('json-prettify-input').fill('{"a":1}');
37+
38+
// Change indent size to 2
39+
await page.locator('label:has-text("Indent size")').locator('input[type="number"]').fill('2');
40+
41+
const formattedJson = await page.getByTestId('area-content').innerText();
42+
43+
// Should use 2-space indentation
44+
expect(formattedJson).toContain(' "a": 1');
45+
});
46+
47+
test('auto-unescape functionality works with escaped JSON', async ({ page }) => {
48+
const escapedJson = '"{\\\"id\\\":\\\"123\\\",\\\"name\\\":\\\"test\\\"}"';
49+
50+
await page.getByTestId('json-prettify-input').fill(escapedJson);
51+
52+
// Enable auto-unescape
53+
await page.locator('label:has-text("Auto-unescape")').locator('input[type="checkbox"]').click();
54+
55+
const unescapedJson = await page.getByTestId('area-content').innerText();
56+
57+
expect(unescapedJson).toContain('"id": "123"');
58+
expect(unescapedJson).toContain('"name": "test"');
59+
expect(unescapedJson).not.toContain('\\"');
60+
});
61+
62+
test('auto-unescape toggle affects validation', async ({ page }) => {
63+
const escapedJson = '"{\\\"valid\\\":\\\"json\\\"}"';
64+
65+
// First, paste escaped JSON without auto-unescape (should show validation error)
66+
await page.getByTestId('json-prettify-input').fill(escapedJson);
67+
68+
// Should show validation error
69+
await expect(page.locator('text=Provided JSON is not valid.')).toBeVisible();
70+
71+
// Enable auto-unescape
72+
await page.locator('label:has-text("Auto-unescape")').locator('input[type="checkbox"]').click();
73+
74+
// Validation error should disappear
75+
await expect(page.locator('text=Provided JSON is not valid.')).not.toBeVisible();
76+
77+
// Output should be properly formatted
78+
const formattedJson = await page.getByTestId('area-content').innerText();
79+
expect(formattedJson).toContain('"valid": "json"');
80+
});
81+
82+
test('displays helpful placeholder text', async ({ page }) => {
83+
const textarea = page.getByTestId('json-prettify-input');
84+
85+
await expect(textarea).toHaveAttribute('placeholder', /auto-unescape.*escaped json/i);
86+
});
87+
});

src/tools/json-viewer/json-viewer.vue

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,61 @@ const inputElement = ref<HTMLElement>();
1111
const rawJson = useStorage('json-prettify:raw-json', '{"hello": "world", "foo": "bar"}');
1212
const indentSize = useStorage('json-prettify:indent-size', 3);
1313
const sortKeys = useStorage('json-prettify:sort-keys', true);
14-
const cleanJson = computed(() => withDefaultOnError(() => formatJson({ rawJson, indentSize, sortKeys }), ''));
14+
const autoUnescape = useStorage('json-prettify:auto-unescape', false);
15+
const cleanJson = computed(() => withDefaultOnError(() => formatJson({ rawJson, indentSize, sortKeys, autoUnescape }), ''));
1516
1617
const rawJsonValidation = useValidation({
1718
source: rawJson,
1819
rules: [
1920
{
20-
validator: v => v === '' || JSON5.parse(v),
21+
validator: (v: string) => {
22+
if (v === '') {
23+
return true;
24+
}
25+
try {
26+
let jsonString = v;
27+
if (autoUnescape.value) {
28+
// Apply the same unescaping logic for validation
29+
jsonString = jsonString.trim();
30+
31+
if ((jsonString.startsWith('"') && jsonString.endsWith('"'))
32+
|| (jsonString.startsWith('\'') && jsonString.endsWith('\''))) {
33+
jsonString = jsonString.slice(1, -1);
34+
}
35+
36+
jsonString = jsonString
37+
.replace(/\\"/g, '"')
38+
.replace(/\\\\/g, '\\')
39+
.replace(/\\n/g, '\n')
40+
.replace(/\\r/g, '\r')
41+
.replace(/\\t/g, '\t')
42+
.replace(/\\f/g, '\f')
43+
.replace(/\\b/g, '\b')
44+
.replace(/\\\//g, '/');
45+
}
46+
JSON5.parse(jsonString);
47+
return true;
48+
}
49+
catch {
50+
return false;
51+
}
52+
},
2153
message: 'Provided JSON is not valid.',
2254
},
2355
],
56+
watch: [autoUnescape],
2457
});
2558
</script>
2659

2760
<template>
2861
<div style="flex: 0 0 100%">
29-
<div style="margin: 0 auto; max-width: 600px" flex justify-center gap-3>
62+
<div style="margin: 0 auto; max-width: 700px" flex flex-wrap justify-center gap-3>
3063
<n-form-item label="Sort keys :" label-placement="left" label-width="100">
3164
<n-switch v-model:value="sortKeys" />
3265
</n-form-item>
66+
<n-form-item label="Auto-unescape :" label-placement="left" label-width="130">
67+
<n-switch v-model:value="autoUnescape" />
68+
</n-form-item>
3369
<n-form-item label="Indent size :" label-placement="left" label-width="100" :show-feedback="false">
3470
<n-input-number v-model:value="indentSize" min="0" max="10" style="width: 100px" />
3571
</n-form-item>
@@ -44,14 +80,15 @@ const rawJsonValidation = useValidation({
4480
<c-input-text
4581
ref="inputElement"
4682
v-model:value="rawJson"
47-
placeholder="Paste your raw JSON here..."
83+
placeholder="Paste your raw JSON here... Enable 'Auto-unescape' for escaped JSON strings"
4884
rows="20"
4985
multiline
5086
autocomplete="off"
5187
autocorrect="off"
5288
autocapitalize="off"
5389
spellcheck="false"
5490
monospace
91+
test-id="json-prettify-input"
5592
/>
5693
</n-form-item>
5794
<n-form-item label="Prettified version of your JSON">

src/tools/json-viewer/json.models.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest';
2-
import { sortObjectKeys } from './json.models';
2+
import { ref } from 'vue';
3+
import { formatJson, sortObjectKeys } from './json.models';
34

45
describe('json models', () => {
56
describe('sortObjectKeys', () => {
@@ -13,4 +14,105 @@ describe('json models', () => {
1314
);
1415
});
1516
});
17+
18+
describe('formatJson', () => {
19+
const testJson = '{"b": 2, "a": 1}';
20+
const expectedSorted = '{\n "a": 1,\n "b": 2\n}';
21+
const expectedUnsorted = '{\n "b": 2,\n "a": 1\n}';
22+
23+
it('formats JSON with default options (sorted keys, 3 spaces)', () => {
24+
const result = formatJson({ rawJson: testJson });
25+
expect(result).toBe(expectedSorted);
26+
});
27+
28+
it('formats JSON without sorting keys when sortKeys is false', () => {
29+
const result = formatJson({ rawJson: testJson, sortKeys: false });
30+
expect(result).toBe(expectedUnsorted);
31+
});
32+
33+
it('formats JSON with custom indent size', () => {
34+
const result = formatJson({ rawJson: testJson, indentSize: 2 });
35+
const expected = '{\n "a": 1,\n "b": 2\n}';
36+
expect(result).toBe(expected);
37+
});
38+
39+
it('works with reactive refs', () => {
40+
const rawJsonRef = ref(testJson);
41+
const sortKeysRef = ref(true);
42+
const indentSizeRef = ref(3);
43+
44+
const result = formatJson({
45+
rawJson: rawJsonRef,
46+
sortKeys: sortKeysRef,
47+
indentSize: indentSizeRef,
48+
});
49+
expect(result).toBe(expectedSorted);
50+
});
51+
52+
describe('autoUnescape functionality', () => {
53+
it('unescapes escaped JSON strings when autoUnescape is true', () => {
54+
const escapedJson = '"{\\\"id\\\":\\\"123\\\",\\\"name\\\":\\\"test\\\"}"';
55+
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
56+
const expected = '{\n "id": "123",\n "name": "test"\n}';
57+
expect(result).toBe(expected);
58+
});
59+
60+
it('handles escaped JSON without outer quotes', () => {
61+
const escapedJson = '{\\\"id\\\":\\\"123\\\",\\\"name\\\":\\\"test\\\"}';
62+
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
63+
const expected = '{\n "id": "123",\n "name": "test"\n}';
64+
expect(result).toBe(expected);
65+
});
66+
67+
it('unescapes various escape sequences', () => {
68+
const escapedJson = '{\\\"text\\\":\\\"Hello\\\\\\\\World\\\",\\\"path\\\":\\\"/api\\\\/test\\\"}';
69+
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
70+
const expected = '{\n "path": "/api/test",\n "text": "Hello\\\\World"\n}';
71+
expect(result).toBe(expected);
72+
});
73+
74+
it('handles single-quoted outer strings', () => {
75+
const escapedJson = '\'{\\\"id\\\":\\\"123\\\"}\'';
76+
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
77+
const expected = '{\n "id": "123"\n}';
78+
expect(result).toBe(expected);
79+
});
80+
81+
it('processes regular JSON normally when autoUnescape is false', () => {
82+
const normalJson = '{"id":"123","name":"test"}';
83+
const result = formatJson({ rawJson: normalJson, autoUnescape: false, indentSize: 2 });
84+
const expected = '{\n "id": "123",\n "name": "test"\n}';
85+
expect(result).toBe(expected);
86+
});
87+
88+
it('handles malformed escaped JSON gracefully', () => {
89+
const malformedJson = '"{\\\"incomplete';
90+
// Should fall back to original string and fail parsing
91+
expect(() => formatJson({ rawJson: malformedJson, autoUnescape: true })).toThrow();
92+
});
93+
94+
it('works with complex nested objects', () => {
95+
const complexEscaped = '"{\\\"users\\\":[{\\\"id\\\":\\\"1\\\",\\\"data\\\":{\\\"active\\\":true}}],\\\"meta\\\":{\\\"total\\\":1}}"';
96+
const result = formatJson({ rawJson: complexEscaped, autoUnescape: true, indentSize: 2 });
97+
const expected = '{\n "meta": {\n "total": 1\n },\n "users": [\n {\n "data": {\n "active": true\n },\n "id": "1"\n }\n ]\n}';
98+
expect(result).toBe(expected);
99+
});
100+
101+
it('works with reactive autoUnescape ref', () => {
102+
const escapedJson = '"{\\\"test\\\":\\\"value\\\"}"';
103+
const autoUnescapeRef = ref(true);
104+
const result = formatJson({ rawJson: escapedJson, autoUnescape: autoUnescapeRef, indentSize: 2 });
105+
const expected = '{\n "test": "value"\n}';
106+
expect(result).toBe(expected);
107+
});
108+
});
109+
110+
it('handles empty string input', () => {
111+
expect(() => formatJson({ rawJson: '' })).toThrow();
112+
});
113+
114+
it('handles invalid JSON input', () => {
115+
expect(() => formatJson({ rawJson: 'invalid json' })).toThrow();
116+
});
117+
});
16118
});

src/tools/json-viewer/json.models.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,55 @@ function sortObjectKeys<T>(obj: T): T {
2020
}, {} as Record<string, unknown>) as T;
2121
}
2222

23+
function unescapeJson(jsonString: string): string {
24+
try {
25+
// First, try to handle double-escaped scenarios
26+
let result = jsonString.trim();
27+
28+
// If the string starts and ends with quotes, and contains escaped quotes inside,
29+
// it might be a JSON string that needs to be unescaped
30+
if ((result.startsWith('"') && result.endsWith('"'))
31+
|| (result.startsWith('\'') && result.endsWith('\''))) {
32+
// Remove outer quotes first
33+
result = result.slice(1, -1);
34+
}
35+
36+
// Handle common escape sequences
37+
result = result
38+
.replace(/\\"/g, '"') // Unescape quotes
39+
.replace(/\\\\/g, '\\') // Unescape backslashes (do this after quotes!)
40+
.replace(/\\n/g, '\n') // Unescape newlines
41+
.replace(/\\r/g, '\r') // Unescape carriage returns
42+
.replace(/\\t/g, '\t') // Unescape tabs
43+
.replace(/\\f/g, '\f') // Unescape form feeds
44+
.replace(/\\b/g, '\b') // Unescape backspaces
45+
.replace(/\\\//g, '/'); // Unescape forward slashes
46+
47+
return result;
48+
}
49+
catch {
50+
return jsonString;
51+
}
52+
}
53+
2354
function formatJson({
2455
rawJson,
2556
sortKeys = true,
2657
indentSize = 3,
58+
autoUnescape = false,
2759
}: {
2860
rawJson: MaybeRef<string>
2961
sortKeys?: MaybeRef<boolean>
3062
indentSize?: MaybeRef<number>
63+
autoUnescape?: MaybeRef<boolean>
3164
}) {
32-
const parsedObject = JSON5.parse(get(rawJson));
65+
let jsonString = get(rawJson);
66+
67+
if (get(autoUnescape)) {
68+
jsonString = unescapeJson(jsonString);
69+
}
70+
71+
const parsedObject = JSON5.parse(jsonString);
3372

3473
return JSON.stringify(get(sortKeys) ? sortObjectKeys(parsedObject) : parsedObject, null, get(indentSize));
3574
}

0 commit comments

Comments
 (0)