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
8 changes: 3 additions & 5 deletions src/contracts/json-format.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { formatJsonLosslessly } from "../utils/lossless-json.js";

const jsonFormat = {
id: "json-format",
name: "JSON Format/Minify",
Expand All @@ -12,11 +14,7 @@ const jsonFormat = {
const text = String(input).trim();

try {
const parsed = JSON.parse(text);
if (mode === "minify") {
return JSON.stringify(parsed);
}
return JSON.stringify(parsed, null, indent);
return formatJsonLosslessly(text, mode === "minify" ? 0 : indent);
} catch (err) {
return `[Error: ${err.message}]`;
}
Expand Down
34 changes: 34 additions & 0 deletions src/contracts/json-format.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,40 @@ describe("json-format transform", () => {
expect(result).toBe('{"a":1,"b":2}');
});

it("preserves every numeric and string lexeme while formatting", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: These new contract tests duplicate the exact fixture and expected outputs already present in src/utils/lossless-json.test.js (LOSSLESS_INPUT, LOSSLESS_PRETTY). The escaped-string lexeme is subtle and error-prone, and maintaining two independent copies means a fix or coverage change in one fixture silently drifts from the other. Keep the contract-level tests (they do verify the transform wrapper), but share the fixture instead of copying it: export LOSSLESS_INPUT/LOSSLESS_PRETTY from lossless-json.test.js (or move them to a shared fixture module) and import them here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/contracts/json-format.test.js, line 21:

<comment>These new contract tests duplicate the exact fixture and expected outputs already present in src/utils/lossless-json.test.js (LOSSLESS_INPUT, LOSSLESS_PRETTY). The escaped-string lexeme is subtle and error-prone, and maintaining two independent copies means a fix or coverage change in one fixture silently drifts from the other. Keep the contract-level tests (they do verify the transform wrapper), but share the fixture instead of copying it: export LOSSLESS_INPUT/LOSSLESS_PRETTY from lossless-json.test.js (or move them to a shared fixture module) and import them here.</comment>

<file context>
@@ -18,6 +18,40 @@ describe("json-format transform", () => {
     expect(result).toBe('{"a":1,"b":2}');
   });
 
+  it("preserves every numeric and string lexeme while formatting", () => {
+    const input =
+      '{"max":9007199254740993,"min":-9007199254740993,"huge":1e400,"fixed":1.2300,"negativeZero":-0,"nested":[[9007199254740993],{"text":"quote: \\" slash: \\\\"}]}';
</file context>

const input =
'{"max":9007199254740993,"min":-9007199254740993,"huge":1e400,"fixed":1.2300,"negativeZero":-0,"nested":[[9007199254740993],{"text":"quote: \\" slash: \\\\"}]}';

expect(jsonFormat.transform(input, { mode: "format", indent: 2 })).toBe(
[
"{",
' "max": 9007199254740993,',
' "min": -9007199254740993,',
' "huge": 1e400,',
' "fixed": 1.2300,',
' "negativeZero": -0,',
' "nested": [',
" [",
" 9007199254740993",
" ],",
" {",
' "text": "quote: \\" slash: \\\\"',
" }",
" ]",
"}",
].join("\n"),
);
});

it("preserves every numeric and string lexeme while minifying", () => {
const input =
' { "max" : 9007199254740993, "min" : -9007199254740993, "huge" : 1e400, "fixed" : 1.2300, "negativeZero" : -0, "nested" : [ [ 9007199254740993 ], { "text" : "quote: \\" slash: \\\\" } ] } ';

expect(jsonFormat.transform(input, { mode: "minify" })).toBe(
'{"max":9007199254740993,"min":-9007199254740993,"huge":1e400,"fixed":1.2300,"negativeZero":-0,"nested":[[9007199254740993],{"text":"quote: \\" slash: \\\\"}]}',
);
});

it("defaults to format", () => {
const result = jsonFormat.transform('{"x":1}');
expect(result).toContain("\n");
Expand Down
6 changes: 4 additions & 2 deletions src/routes/json-formatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
t,
} from "../utils/i18n.js";
import { countKeys } from "../utils/json-stats.js";
import { formatJsonLosslessly } from "../utils/lossless-json.js";

export async function handleJSONFormatterRoutes(request, url) {
const { pathname } = url;
Expand Down Expand Up @@ -193,6 +194,7 @@ function renderJSONFormatterPage(lang = "en") {
// Inlined from src/utils/json-stats.js — keep in sync via the import above.
// The same function powers the unit tests in src/utils/json-stats.test.js.
${countKeys.toString()}
${formatJsonLosslessly.toString()}

function updateStats(jsonObj, formatted) {
var result = countKeys(jsonObj);
Expand All @@ -216,8 +218,8 @@ function renderJSONFormatterPage(lang = "en") {
try {
var input = inputEditor.getValue().trim();
if (!input) return showStatus(_t('tools.json-formatter.js.status0', 'Please enter JSON'), 'error');
var formatted = formatJsonLosslessly(input, 2);
var parsed = JSON.parse(input);
var formatted = JSON.stringify(parsed, null, 2);
outputEl.value = formatted;
outputEditor.setValue(formatted);
document.getElementById('json-empty-state').classList.add('hidden');
Expand All @@ -233,8 +235,8 @@ function renderJSONFormatterPage(lang = "en") {
try {
var input = inputEditor.getValue().trim();
if (!input) return showStatus(_t('tools.json-formatter.js.status0', 'Please enter JSON'), 'error');
var minified = formatJsonLosslessly(input, 0);
var parsed = JSON.parse(input);
var minified = JSON.stringify(parsed);
outputEl.value = minified;
outputEditor.setValue(minified);
document.getElementById('json-empty-state').classList.add('hidden');
Expand Down
17 changes: 17 additions & 0 deletions src/routes/json-formatter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { handleJSONFormatterRoutes } from "./json-formatter.js";

describe("JSON formatter route", () => {
it("wires the lossless formatter into both browser actions", async () => {
const request = new Request("https://example.com/json-formatter");
const response = await handleJSONFormatterRoutes(
request,
new URL(request.url),
);
const html = await response.text();

expect(html).toContain("function formatJsonLosslessly");
expect(html).toContain("formatJsonLosslessly(input, 2)");
expect(html).toContain("formatJsonLosslessly(input, 0)");
});
});
89 changes: 89 additions & 0 deletions src/utils/lossless-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Validate JSON and change only insignificant whitespace.
*
* JSON.parse remains the syntax oracle, while the lexical pass deliberately
* avoids serializing parsed numbers through JavaScript's Number type.
*/
export function formatJsonLosslessly(input, indent = 2) {
const text = String(input).trim();
JSON.parse(text);

let gap = "";
if (typeof indent === "number") {
const width = Math.min(10, Math.max(0, Math.trunc(indent)));
gap = " ".repeat(width);
} else if (typeof indent === "string") {
gap = indent.slice(0, 10);
}

let output = "";
let depth = 0;
let inString = false;
let escaped = false;
let previousToken = "";

for (let index = 0; index < text.length; index += 1) {
const character = text[index];

if (inString) {
output += character;
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === '"') {
inString = false;
}
continue;
}

if (
character === " " ||
character === "\t" ||
character === "\n" ||
character === "\r"
) {
continue;
}

if (character === '"') {
inString = true;
output += character;
} else if (character === "{" || character === "[") {
output += character;
depth += 1;

if (gap) {
let nextIndex = index + 1;
while (
text[nextIndex] === " " ||
text[nextIndex] === "\t" ||
text[nextIndex] === "\n" ||
text[nextIndex] === "\r"
) {
nextIndex += 1;
}
const closing = character === "{" ? "}" : "]";
if (text[nextIndex] !== closing) {
output += `\n${gap.repeat(depth)}`;
}
}
} else if (character === "}" || character === "]") {
depth -= 1;
if (gap && previousToken !== "{" && previousToken !== "[") {
output += `\n${gap.repeat(depth)}`;
}
output += character;
} else if (character === ",") {
output += gap ? `,\n${gap.repeat(depth)}` : ",";
} else if (character === ":") {
output += gap ? ": " : ":";
} else {
output += character;
}

previousToken = character;
}

return output;
}
60 changes: 60 additions & 0 deletions src/utils/lossless-json.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { formatJsonLosslessly } from "./lossless-json.js";

const LOSSLESS_INPUT =
'{"max":9007199254740993,"min":-9007199254740993,"huge":1e400,"fixed":1.2300,"negativeZero":-0,"nested":[[9007199254740993],{"text":"quote: \\" slash: \\\\"}]}';

const LOSSLESS_PRETTY = [
"{",
' "max": 9007199254740993,',
' "min": -9007199254740993,',
' "huge": 1e400,',
' "fixed": 1.2300,',
' "negativeZero": -0,',
' "nested": [',
" [",
" 9007199254740993",
" ],",
" {",
' "text": "quote: \\" slash: \\\\"',
" }",
" ]",
"}",
].join("\n");

describe("formatJsonLosslessly", () => {
it("preserves numeric and escaped-string lexemes while formatting", () => {
expect(formatJsonLosslessly(LOSSLESS_INPUT, 2)).toBe(LOSSLESS_PRETTY);
});

it("preserves numeric and escaped-string lexemes while minifying", () => {
const spaced =
' { "max" : 9007199254740993, "min" : -9007199254740993, "huge" : 1e400, "fixed" : 1.2300, "negativeZero" : -0, "nested" : [ [ 9007199254740993 ], { "text" : "quote: \\" slash: \\\\" } ] } ';
expect(formatJsonLosslessly(spaced, 0)).toBe(LOSSLESS_INPUT);
});

it("formats ordinary nested JSON with the requested indentation", () => {
expect(formatJsonLosslessly('{"a":1,"b":[true,false]}', 4)).toBe(
[
"{",
' "a": 1,',
' "b": [',
" true,",
" false",
" ]",
"}",
].join("\n"),
);
});

it("matches JSON.stringify indentation options for empty and nested values", () => {
expect(formatJsonLosslessly('{"empty":{},"items":[]}', "\t")).toBe(
['{', '\t"empty": {},', '\t"items": []', '}'].join("\n"),
);
expect(formatJsonLosslessly('{"a":1}', null)).toBe('{"a":1}');
});

it("rejects malformed JSON with the native parse error", () => {
expect(() => formatJsonLosslessly('{"a":}', 2)).toThrow(SyntaxError);
});
});
90 changes: 90 additions & 0 deletions tests/e2e/json-formatter-lossless.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { test, expect } from "@playwright/test";

const evidenceDirectory = process.env.JSON_EVIDENCE_DIR;
const input = '{"id":9007199254740993,"ok":true}';
const pretty = ['{', ' "id": 9007199254740993,', ' "ok": true', '}'].join("\n");

test("desktop Format Minify and Copy preserve an unsafe integer", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto("/json-formatter");
await page.locator("#re-input").fill(input);

await page.locator("#format-btn").click();
await expect(page.locator("#json-output")).toHaveValue(pretty);
await expect(page.locator("#re-output")).toHaveText(pretty);
await expect(page.locator("#status-indicator")).toHaveText(/Valid/);
await page.locator("#re-output-wrap").scrollIntoViewIfNeeded();
if (evidenceDirectory) {
await page.screenshot({
path: `${evidenceDirectory}/json-desktop-1440x1000.png`,
});
}

await page.locator("#minify-btn").click();
await expect(page.locator("#json-output")).toHaveValue(input);
await expect(page.locator("#re-output")).toHaveText(input);
await page.evaluate(() => {
const nativeWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
window.clipboardWriteCompleted = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Clipboard write did not complete")), 5000);
navigator.clipboard.writeText = async (text) => {
try {
await nativeWriteText(text);
clearTimeout(timeout);
resolve();
} catch (error) {
clearTimeout(timeout);
reject(error);
throw error;
} finally {
navigator.clipboard.writeText = nativeWriteText;
}
};
});
});
await page.locator("#copy-btn").click();
await page.evaluate(() => window.clipboardWriteCompleted);
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(input);
});

test("malformed input errors and clears stale success output", async ({ page }) => {
await page.goto("/json-formatter");
await page.locator("#re-input").fill('{"ok":true}');
await page.locator("#format-btn").click();
await expect(page.locator("#status-indicator")).toHaveText(/Valid/);

await page.locator("#re-input").fill('{"id":}');
await page.locator("#format-btn").click();
await expect(page.locator("#status-indicator")).toHaveText(/Invalid/);
await expect(page.locator("#status-content")).not.toBeEmpty();
await expect(page.locator("#json-output")).toHaveValue("");
await expect(page.locator("#re-output")).toHaveText("");
});

test("mobile JSON formatting preserves ordinary structure and numeric lexemes", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/json-formatter");
await page.locator("#re-input").fill('{"a":1,"b":[true,false]}');
await page.locator("#format-btn").click();
await expect(page.locator("#json-output")).toHaveValue(
['{', ' "a": 1,', ' "b": [', " true,", " false", " ]", '}'].join("\n"),
);
await expect(page.locator("#status-indicator")).toHaveText(/Valid/);

const preciseInput = '{"id":9007199254740993,"ok":true}';
await page.locator("#re-input").fill(preciseInput);
await page.locator("#format-btn").click();
await expect(page.locator("#json-output")).toHaveValue(
['{', ' "id": 9007199254740993,', ' "ok": true', '}'].join("\n"),
);
await page.locator("#re-output-wrap").scrollIntoViewIfNeeded();
if (evidenceDirectory) {
await page.screenshot({
path: `${evidenceDirectory}/json-mobile-390x844.png`,
});
}
await page.locator("#minify-btn").click();
await expect(page.locator("#json-output")).toHaveValue(preciseInput);
});