Skip to content

Commit 8428e67

Browse files
committed
feat: add JSON Studio developer tool
- new /tools/json-studio route: format, minify, validate, and explore JSON entirely client-side — editor synced with a live status (Ready/Valid/Invalid, never color-only), a collapsible tree viewer with Expand/Collapse all, and compact stats (root type, keys, depth, byte size) - open a local .json file (drag-and-drop or picker), download the formatted result, load a bundled example, and persist editor content to localStorage (SSR-safe useSyncExternalStore hook, same pattern already used by InfraLens's history) - Ctrl/Cmd+Shift+F formats the editor - zero new dependency — built entirely on JSON.parse/stringify, Blob, FileReader, TextEncoder, and the Clipboard API - shares ToolPageShell/ToolHeader with Cron Builder and InfraLens, and gets its own /tools registry entry - 35 unit tests (parsing/formatting/stats) + 10 Playwright e2e tests including an axe accessibility check
1 parent c650be4 commit 8428e67

25 files changed

Lines changed: 1474 additions & 1 deletion

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ InfraLens's history as a standalone product (2026-01-06 to 2026-08-10) is
99
frozen in [`docs/infralens/CHANGELOG.md`](docs/infralens/CHANGELOG.md).
1010
InfraLens changes since its native migration are recorded here.
1111

12+
## [1.3.0] — 2026-08-15
13+
14+
### Added
15+
16+
- **JSON Studio** (`/tools/json-studio`) — a new developer tool for
17+
formatting, validating, minifying, and exploring JSON directly in
18+
the browser. A large editor stays in sync with a live-computed
19+
validity status, a compact stats line (root type, key count, depth,
20+
byte size), and a collapsible tree viewer with Expand/Collapse all.
21+
Also supports loading a local `.json` file (drag-and-drop or file
22+
picker), downloading the formatted result, a bundled example, and
23+
content persisted to `localStorage` across visits. Parsing,
24+
formatting, and statistics are built entirely on native `JSON.parse`/
25+
`JSON.stringify` — no editor or JSON dependency was added. Everything
26+
runs client-side; JSON content never leaves the browser.
27+
1228
## [1.2.1] — 2026-08-15
1329

1430
### Fixed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ app/
6464
tools/ # Developer tools
6565
infralens/ # InfraLens — native routes (analyze, compare, docs, privacy)
6666
cron-builder/ # Cron Builder — visual cron expression editor
67+
json-studio/ # JSON Studio — format, validate and explore JSON
6768
rss.xml/ # RSS feed
6869
sitemap.ts, robots.ts, manifest.ts, opengraph-image.tsx
6970
@@ -73,6 +74,7 @@ src/
7374
lib/ # Content registries, brand tokens, navigation, JSON-LD
7475
infralens/ # InfraLens engine — checks, security/SSRF, DNS, scoring, history
7576
cron-builder/ # Cron Builder engine — parsing, validation, next-run calculation
77+
json-studio/ # JSON Studio engine — parsing, formatting, stats, storage
7678
7779
docs/
7880
infralens/ # InfraLens developer docs, changelog, security policy, MIT license

app/sitemap.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,21 @@ export default function sitemap(): MetadataRoute.Sitemap {
100100
},
101101
];
102102

103+
const jsonStudioRoutes: MetadataRoute.Sitemap = [
104+
{
105+
url: `${BASE_URL}/tools/json-studio`,
106+
lastModified: new Date(),
107+
changeFrequency: "monthly",
108+
priority: 0.6,
109+
},
110+
];
111+
103112
return [
104113
...staticRoutes,
105114
...articleRoutes,
106115
...projectRoutes,
107116
...infralensRoutes,
108117
...cronBuilderRoutes,
118+
...jsonStudioRoutes,
109119
];
110120
}

app/tools/json-studio/page.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ToolHeader } from "@/components/layout/tool-header";
2+
import { ToolPageShell } from "@/components/layout/tool-page-shell";
3+
import { JsonStudio } from "@/json-studio/components/json-studio";
4+
import { brand } from "@/lib/brand";
5+
import { Braces } from "lucide-react";
6+
import type { Metadata, Viewport } from "next";
7+
8+
export const metadata: Metadata = {
9+
title: "JSON Studio — Randy Code",
10+
description:
11+
"Format, validate, minify and inspect JSON directly in your browser.",
12+
alternates: { canonical: "/tools/json-studio" },
13+
};
14+
15+
export const viewport: Viewport = {
16+
themeColor: brand.colors.green[500],
17+
};
18+
19+
export default function JsonStudioPage() {
20+
return (
21+
<ToolPageShell>
22+
<main className="flex min-h-screen flex-col bg-background px-6 pb-16 text-foreground">
23+
<div className="mx-auto w-full max-w-4xl">
24+
<ToolHeader
25+
icon={Braces}
26+
label="Developer Tool"
27+
title="JSON Studio"
28+
tagline="Validate, format and inspect JSON instantly."
29+
color={brand.colors.green[500]}
30+
/>
31+
<JsonStudio />
32+
</div>
33+
</main>
34+
</ToolPageShell>
35+
);
36+
}

e2e/json-studio.spec.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import AxeBuilder from "@axe-core/playwright";
2+
import { expect, test } from "@playwright/test";
3+
4+
const SEVERE_IMPACTS = ["serious", "critical"];
5+
const SAMPLE = '{\n "a": 1,\n "b": {\n "c": 2\n }\n}';
6+
7+
test.describe("json studio", () => {
8+
test("route loads with an empty, ready state", async ({ page }) => {
9+
await page.goto("/tools/json-studio");
10+
11+
await expect(page.locator("h1")).toHaveText("JSON Studio");
12+
await expect(page.locator("#json-studio-editor")).toHaveValue("");
13+
await expect(page.getByRole("status")).toHaveText("Ready");
14+
await expect(
15+
page.getByText("Your JSON structure will appear here."),
16+
).toBeVisible();
17+
});
18+
19+
test("loading the example produces valid JSON and a tree", async ({
20+
page,
21+
}) => {
22+
await page.goto("/tools/json-studio");
23+
24+
await page.getByRole("button", { name: "Load example" }).click();
25+
26+
await expect(page.getByRole("status")).toHaveText("Valid JSON");
27+
await expect(page.getByText(/Object · \d+ keys · Depth \d+/)).toBeVisible();
28+
await expect(
29+
page.getByRole("button", { name: "Collapse root" }),
30+
).toBeVisible();
31+
});
32+
33+
test("format pretty-prints and minify compacts", async ({ page }) => {
34+
await page.goto("/tools/json-studio");
35+
await page.fill("#json-studio-editor", '{"a":1,"b":{"c":2}}');
36+
37+
await page.getByRole("button", { name: "Format" }).click();
38+
await expect(page.locator("#json-studio-editor")).toHaveValue(SAMPLE);
39+
40+
await page.getByRole("button", { name: "Minify" }).click();
41+
await expect(page.locator("#json-studio-editor")).toHaveValue(
42+
'{"a":1,"b":{"c":2}}',
43+
);
44+
});
45+
46+
test("invalid JSON is rejected without destroying the source", async ({
47+
page,
48+
}) => {
49+
await page.goto("/tools/json-studio");
50+
const invalid = '{"a": 1, "b": }';
51+
52+
await page.fill("#json-studio-editor", invalid);
53+
54+
await expect(page.getByRole("status")).toHaveText("Invalid JSON");
55+
await expect(page.locator("#json-studio-editor")).toHaveValue(invalid);
56+
await expect(
57+
page.getByText("Fix the JSON syntax to explore its structure."),
58+
).toBeVisible();
59+
await expect(page.getByRole("button", { name: "Format" })).toBeDisabled();
60+
await expect(page.getByRole("button", { name: "Download" })).toBeDisabled();
61+
});
62+
63+
test("clearing resets editor, status and tree", async ({ page }) => {
64+
await page.goto("/tools/json-studio");
65+
await page.getByRole("button", { name: "Load example" }).click();
66+
67+
await page.getByRole("button", { name: "Clear" }).click();
68+
69+
await expect(page.locator("#json-studio-editor")).toHaveValue("");
70+
await expect(page.getByRole("status")).toHaveText("Ready");
71+
await expect(
72+
page.getByText("Your JSON structure will appear here."),
73+
).toBeVisible();
74+
});
75+
76+
test("copying the content exposes a success state", async ({ page }) => {
77+
await page.addInitScript(() => {
78+
(window as unknown as { __copied: string[] }).__copied = [];
79+
Object.defineProperty(navigator, "clipboard", {
80+
configurable: true,
81+
value: {
82+
writeText: (text: string) => {
83+
(window as unknown as { __copied: string[] }).__copied.push(text);
84+
return Promise.resolve();
85+
},
86+
},
87+
});
88+
});
89+
await page.goto("/tools/json-studio");
90+
await page.fill("#json-studio-editor", '{"a":1}');
91+
92+
await page.getByRole("button", { name: "Copy" }).click();
93+
94+
await expect(page.getByRole("button", { name: "Copied" })).toBeVisible();
95+
const copied = await page.evaluate(
96+
() => (window as unknown as { __copied: string[] }).__copied,
97+
);
98+
expect(copied).toEqual(['{"a":1}']);
99+
});
100+
101+
test("opening a local file loads, validates and updates the tree", async ({
102+
page,
103+
}) => {
104+
await page.goto("/tools/json-studio");
105+
106+
await page.getByLabel("Open a JSON file").setInputFiles({
107+
name: "sample.json",
108+
mimeType: "application/json",
109+
buffer: Buffer.from('{"fromFile":true}'),
110+
});
111+
112+
await expect(page.locator("#json-studio-editor")).toHaveValue(
113+
'{"fromFile":true}',
114+
);
115+
await expect(page.getByRole("status")).toHaveText("Valid JSON");
116+
});
117+
118+
test("downloading valid JSON triggers a .json file with the formatted content", async ({
119+
page,
120+
}) => {
121+
await page.goto("/tools/json-studio");
122+
await page.fill("#json-studio-editor", '{"a":1}');
123+
124+
const [download] = await Promise.all([
125+
page.waitForEvent("download"),
126+
page.getByRole("button", { name: "Download" }).click(),
127+
]);
128+
129+
expect(download.suggestedFilename()).toBe("data.json");
130+
});
131+
132+
test("mobile viewport has no horizontal overflow", async ({ page }) => {
133+
await page.setViewportSize({ width: 375, height: 812 });
134+
await page.goto("/tools/json-studio");
135+
136+
const hasOverflow = await page.evaluate(
137+
() =>
138+
document.documentElement.scrollWidth >
139+
document.documentElement.clientWidth + 1,
140+
);
141+
expect(hasOverflow).toBe(false);
142+
});
143+
144+
test("has no serious/critical axe violations", async ({ page }) => {
145+
await page.goto("/tools/json-studio");
146+
await page.getByRole("button", { name: "Load example" }).click();
147+
148+
const results = await new AxeBuilder({ page }).analyze();
149+
const severe = results.violations.filter((v) =>
150+
SEVERE_IMPACTS.includes(v.impact ?? ""),
151+
);
152+
153+
expect(
154+
severe,
155+
severe
156+
.map((v) => `${v.id}: ${v.description} (${v.nodes.length} node(s))`)
157+
.join("\n"),
158+
).toEqual([]);
159+
});
160+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "randy-code",
3-
"version": "1.2.1",
3+
"version": "1.3.0",
44
"private": true,
55
"packageManager": "pnpm@10.7.0",
66
"scripts": {

0 commit comments

Comments
 (0)