This commit is contained in:
Qi Yu 2025-10-10 10:55:04 -04:00 committed by GitHub
commit 165a5aea5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 indentSize = useStorage('json-prettify:indent-size', 3);
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({
source: rawJson,
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.',
},
],
watch: [autoUnescape],
});
</script>
<template>
<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-switch v-model:value="sortKeys" />
</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-input-number v-model:value="indentSize" min="0" max="10" style="width: 100px" />
</n-form-item>
@ -44,7 +80,7 @@ const rawJsonValidation = useValidation({
<c-input-text
ref="inputElement"
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"
multiline
autocomplete="off"
@ -52,6 +88,7 @@ const rawJsonValidation = useValidation({
autocapitalize="off"
spellcheck="false"
monospace
test-id="json-prettify-input"
/>
</n-form-item>
<n-form-item label="Prettified version of your JSON">

View File

@ -1,5 +1,6 @@
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('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;
}
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({
rawJson,
sortKeys = true,
indentSize = 3,
autoUnescape = false,
}: {
rawJson: MaybeRef<string>
sortKeys?: MaybeRef<boolean>
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));
}