Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/tools/json-viewer/json-viewer.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 41 additions & 4 deletions src/tools/json-viewer/json-viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -44,14 +80,15 @@ 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"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
monospace
test-id="json-prettify-input"
/>
</n-form-item>
<n-form-item label="Prettified version of your JSON">
Expand Down
104 changes: 103 additions & 1 deletion src/tools/json-viewer/json.models.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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();
});
});
});
41 changes: 40 additions & 1 deletion src/tools/json-viewer/json.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Loading