Skip to content

Commit 4ad6ef1

Browse files
committed
added RepairJSON to fix JSON related issues
1 parent 6f306c4 commit 4ad6ef1

4 files changed

Lines changed: 279 additions & 1 deletion

File tree

src/core/App.js

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const tools = [
77
{ id: 'format-json', name: 'Format & Validate', desc: 'Prettify JSON and detect errors with line numbers', icon: '✦', accent: '#3b82f6', category: 'essentials' },
88
{ id: 'minify-json', name: 'Minify JSON', desc: 'Compress JSON by removing all whitespace', icon: '⊟', accent: '#8b5cf6', category: 'essentials' },
99
{ id: 'sort-keys', name: 'Sort Keys', desc: 'Sort all JSON object keys alphabetically', icon: '⇅', accent: '#06b6d4', category: 'essentials' },
10+
{ id: 'repair-json', name: 'Repair JSON', desc: 'Fix trailing commas, single quotes, unquoted keys and comments', icon: '⚙', accent: '#f43f5e', category: 'essentials' },
1011
// Convert
1112
{ id: 'json-to-csv', name: 'JSON to CSV', desc: 'Convert JSON arrays to CSV spreadsheet format', icon: '⇢', accent: '#10b981', category: 'convert' },
1213
{ id: 'csv-to-json', name: 'CSV to JSON', desc: 'Convert CSV data to a JSON array', icon: '⇠', accent: '#f59e0b', category: 'convert' },
@@ -73,7 +74,7 @@ export default class App {
7374
<span>Browser-based · Zero uploads</span>
7475
</div>
7576
<h1 class="hero-title">JSON <span>Kit</span></h1>
76-
<p class="hero-tagline">12 free JSON tools in your browser — format, convert, diff, query and more. Your data never leaves your device.</p>
77+
<p class="hero-tagline">13 free JSON tools in your browser — format, convert, diff, query and more. Your data never leaves your device.</p>
7778
<div class="hero-props">
7879
<div class="hero-prop">
7980
<div class="hero-prop-icon">🔒</div>
@@ -191,6 +192,7 @@ export default class App {
191192
${this.flattenJsonViewHTML()}
192193
${this.unflattenJsonViewHTML()}
193194
${this.jsonQueryViewHTML()}
195+
${this.repairJsonViewHTML()}
194196
`
195197
}
196198

@@ -719,6 +721,46 @@ export default class App {
719721
`
720722
}
721723

724+
repairJsonViewHTML() {
725+
const t = tools.find(x => x.id === 'repair-json')
726+
return `
727+
<div id="view-repair-json" class="tool-view" role="main">
728+
${this.toolHeaderHTML(t)}
729+
<div class="editor-layout">
730+
<div class="editor-pane">
731+
<div class="editor-pane-header">
732+
<span class="pane-label">Broken JSON</span>
733+
<div class="pane-actions">
734+
<button class="btn btn-ghost btn-sm" id="repair-clear">Clear</button>
735+
</div>
736+
</div>
737+
<div class="editor-pane-body">
738+
<textarea id="repair-input" class="code-area" placeholder="Paste broken JSON here…\n\n// trailing commas, single quotes, unquoted keys\n{name: 'Alice', scores: [10, 20,],}" spellcheck="false" autocomplete="off"></textarea>
739+
</div>
740+
<div class="action-bar">
741+
<button class="btn btn-primary" id="repair-btn">Repair JSON</button>
742+
<div class="stats-bar" id="repair-input-stats"><span>0 chars</span></div>
743+
</div>
744+
</div>
745+
746+
<div class="editor-pane">
747+
<div class="editor-pane-header">
748+
<span class="pane-label">Repaired Output</span>
749+
<div class="pane-actions">
750+
<button class="btn btn-secondary btn-sm" id="repair-copy">Copy</button>
751+
<button class="btn btn-secondary btn-sm" id="repair-download">Download</button>
752+
</div>
753+
</div>
754+
<div class="editor-pane-body">
755+
<div id="repair-output" class="output-area empty">Repaired JSON will appear here…</div>
756+
</div>
757+
<div class="status-message" id="repair-status"></div>
758+
</div>
759+
</div>
760+
</div>
761+
`
762+
}
763+
722764
// ---- Shared tool header ----
723765

724766
toolHeaderHTML(tool) {

src/main.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { init as initJsonSchema } from './tools/JsonSchema.js'
2424
import { init as initFlattenJson } from './tools/FlattenJson.js'
2525
import { init as initUnflattenJson } from './tools/UnflattenJson.js'
2626
import { init as initJsonQuery } from './tools/JsonQuery.js'
27+
import { init as initRepairJson } from './tools/RepairJson.js'
2728

2829
document.addEventListener('DOMContentLoaded', () => {
2930
// Boot the SPA shell
@@ -42,6 +43,7 @@ document.addEventListener('DOMContentLoaded', () => {
4243
initFlattenJson()
4344
initUnflattenJson()
4445
initJsonQuery()
46+
initRepairJson()
4547

4648
// Restore view from URL hash (e.g. direct link to a tool)
4749
app.restoreFromHash()

src/tools/RepairJson.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/* ============================================
2+
RepairJson.js — Auto-fix common JSON issues
3+
============================================ */
4+
5+
import { copyToClipboard, downloadText, countStats } from '../core/Utils.js'
6+
7+
export function init() {
8+
const input = document.getElementById('repair-input')
9+
const output = document.getElementById('repair-output')
10+
const status = document.getElementById('repair-status')
11+
const stats = document.getElementById('repair-input-stats')
12+
13+
if (!input) return
14+
15+
input.addEventListener('input', () => {
16+
const s = countStats(input.value)
17+
stats.innerHTML = `<span>${s.chars.toLocaleString()} chars</span><span>${s.lines} lines</span><span>${s.size}</span>`
18+
})
19+
20+
document.getElementById('repair-btn').addEventListener('click', runRepair)
21+
22+
input.addEventListener('keydown', (e) => {
23+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') runRepair()
24+
})
25+
26+
document.getElementById('repair-clear').addEventListener('click', () => {
27+
input.value = ''
28+
output.textContent = ''
29+
output.className = 'output-area empty'
30+
output.dataset.raw = ''
31+
setStatus('', '')
32+
stats.innerHTML = '<span>0 chars</span>'
33+
})
34+
35+
document.getElementById('repair-copy').addEventListener('click', () => {
36+
const text = output.dataset.raw
37+
if (text) copyToClipboard(text)
38+
})
39+
40+
document.getElementById('repair-download').addEventListener('click', () => {
41+
const text = output.dataset.raw
42+
if (text) downloadText(text, 'repaired.json', 'application/json')
43+
})
44+
45+
function runRepair() {
46+
const raw = input.value.trim()
47+
if (!raw) {
48+
setStatus('Paste broken JSON to repair.', 'info')
49+
return
50+
}
51+
52+
// If it already parses, nothing to fix
53+
try {
54+
const parsed = JSON.parse(raw)
55+
const pretty = JSON.stringify(parsed, null, 2)
56+
output.textContent = pretty
57+
output.className = 'output-area'
58+
output.dataset.raw = pretty
59+
setStatus('✓ JSON is already valid — no repairs needed.', 'success')
60+
return
61+
} catch (_) { /* fall through to repair */ }
62+
63+
const { text, fixes } = repairJson(raw)
64+
65+
try {
66+
const parsed = JSON.parse(text)
67+
const pretty = JSON.stringify(parsed, null, 2)
68+
output.textContent = pretty
69+
output.className = 'output-area'
70+
output.dataset.raw = pretty
71+
const summary = fixes.length
72+
? `✓ Repaired · ${fixes.length} fix${fixes.length !== 1 ? 'es' : ''}: ${fixes.join(', ')}`
73+
: '✓ Repaired'
74+
setStatus(summary, 'success')
75+
} catch (e) {
76+
output.textContent = ''
77+
output.className = 'output-area empty'
78+
output.dataset.raw = ''
79+
setStatus(`✕ Could not repair — ${e.message.split(' at')[0]}`, 'error')
80+
}
81+
}
82+
83+
function setStatus(msg, type) {
84+
status.textContent = msg
85+
status.className = `status-message${msg ? ' show ' + type : ''}`
86+
}
87+
}
88+
89+
/**
90+
* Attempt to repair common JSON issues.
91+
* Returns { text, fixes } where fixes is an array of human-readable descriptions.
92+
*
93+
* Handles:
94+
* - Block comments (/* ... *\/)
95+
* - Line comments (// ...)
96+
* - Single-quoted strings ('value')
97+
* - Unquoted object keys ({key: ...})
98+
* - Trailing commas ([1, 2,] or {"a":1,})
99+
*/
100+
export function repairJson(raw) {
101+
const fixes = []
102+
let text = raw
103+
104+
// 1. Remove block comments /* ... */
105+
const t1 = text.replace(/\/\*[\s\S]*?\*\//g, '')
106+
if (t1 !== text) { text = t1; fixes.push('removed block comments') }
107+
108+
// 2. Remove line comments // ...
109+
const t2 = text.replace(/\/\/[^\n\r]*/g, '')
110+
if (t2 !== text) { text = t2; fixes.push('removed line comments') }
111+
112+
// 3. Convert single-quoted strings to double-quoted
113+
// Matches 'content' where content can contain escaped chars but not unescaped single quotes
114+
const t3 = text.replace(/'((?:[^'\\]|\\.)*)'/g, (_, content) => {
115+
const fixed = content
116+
.replace(/\\'/g, "'") // unescape \' → '
117+
.replace(/"/g, '\\"') // escape any internal " → \"
118+
return `"${fixed}"`
119+
})
120+
if (t3 !== text) { text = t3; fixes.push('converted single quotes to double quotes') }
121+
122+
// 4. Quote unquoted object keys: { key: ... } → { "key": ... }
123+
const t4 = text.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, (_, prefix, key) => `${prefix}"${key}":`)
124+
if (t4 !== text) { text = t4; fixes.push('quoted unquoted keys') }
125+
126+
// 5. Remove trailing commas before } or ]
127+
const t5 = text.replace(/,(\s*[}\]])/g, '$1')
128+
if (t5 !== text) { text = t5; fixes.push('removed trailing commas') }
129+
130+
return { text: text.trim(), fixes }
131+
}

tests/unit/utils.test.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2+
import { repairJson } from '../../src/tools/RepairJson.js'
23
import { countStats, formatBytes, escapeHtml, safeParseJson, prettyJson, extractErrorPosition } from '../../src/core/Utils.js'
34
import { sortKeys } from '../../src/tools/SortKeys.js'
45
import { flattenJson } from '../../src/tools/FlattenJson.js'
@@ -1506,3 +1507,105 @@ describe('parseYaml — additional edge cases', () => {
15061507
expect(result.msg).toBe('hello world')
15071508
})
15081509
})
1510+
1511+
/* ===========================
1512+
repairJson
1513+
=========================== */
1514+
describe('repairJson', () => {
1515+
// --- already valid ---
1516+
it('returns unchanged text for already-valid JSON', () => {
1517+
const { text, fixes } = repairJson('{"a":1}')
1518+
expect(JSON.parse(text)).toEqual({ a: 1 })
1519+
expect(fixes).toHaveLength(0)
1520+
})
1521+
1522+
// --- trailing commas ---
1523+
it('removes trailing comma in object', () => {
1524+
const { text, fixes } = repairJson('{"a":1,"b":2,}')
1525+
expect(JSON.parse(text)).toEqual({ a: 1, b: 2 })
1526+
expect(fixes).toContain('removed trailing commas')
1527+
})
1528+
1529+
it('removes trailing comma in array', () => {
1530+
const { text, fixes } = repairJson('[1, 2, 3,]')
1531+
expect(JSON.parse(text)).toEqual([1, 2, 3])
1532+
expect(fixes).toContain('removed trailing commas')
1533+
})
1534+
1535+
it('removes trailing comma in nested structure', () => {
1536+
const { text } = repairJson('{"a":[1,2,],"b":{"c":3,}}')
1537+
expect(JSON.parse(text)).toEqual({ a: [1, 2], b: { c: 3 } })
1538+
})
1539+
1540+
// --- single quotes ---
1541+
it('converts single-quoted string values', () => {
1542+
const { text, fixes } = repairJson("{'key':'value'}")
1543+
expect(JSON.parse(text)).toEqual({ key: 'value' })
1544+
expect(fixes).toContain('converted single quotes to double quotes')
1545+
})
1546+
1547+
it('converts single-quoted strings with internal double quotes', () => {
1548+
const { text } = repairJson(`{'msg':'say "hi"'}`)
1549+
const parsed = JSON.parse(text)
1550+
expect(parsed.msg).toBe('say "hi"')
1551+
})
1552+
1553+
it('handles escaped single quotes inside single-quoted strings', () => {
1554+
const { text } = repairJson(`{'msg':'it\\'s fine'}`)
1555+
const parsed = JSON.parse(text)
1556+
expect(parsed.msg).toBe("it's fine")
1557+
})
1558+
1559+
// --- unquoted keys ---
1560+
it('quotes unquoted object keys', () => {
1561+
const { text, fixes } = repairJson('{name: "Alice"}')
1562+
expect(JSON.parse(text)).toEqual({ name: 'Alice' })
1563+
expect(fixes).toContain('quoted unquoted keys')
1564+
})
1565+
1566+
it('quotes multiple unquoted keys', () => {
1567+
const { text } = repairJson('{name: "Alice", age: 30}')
1568+
expect(JSON.parse(text)).toEqual({ name: 'Alice', age: 30 })
1569+
})
1570+
1571+
it('does not re-quote already-quoted keys', () => {
1572+
const { fixes } = repairJson('{"name": "Alice"}')
1573+
expect(fixes).not.toContain('quoted unquoted keys')
1574+
})
1575+
1576+
// --- comments ---
1577+
it('removes line comments', () => {
1578+
const { text, fixes } = repairJson('{\n "a": 1 // comment\n}')
1579+
expect(JSON.parse(text)).toEqual({ a: 1 })
1580+
expect(fixes).toContain('removed line comments')
1581+
})
1582+
1583+
it('removes block comments', () => {
1584+
const { text, fixes } = repairJson('{ /* comment */ "a": 1 }')
1585+
expect(JSON.parse(text)).toEqual({ a: 1 })
1586+
expect(fixes).toContain('removed block comments')
1587+
})
1588+
1589+
it('removes multi-line block comments', () => {
1590+
const { text } = repairJson('{\n /* \n * notes\n */\n "a": 1\n}')
1591+
expect(JSON.parse(text)).toEqual({ a: 1 })
1592+
})
1593+
1594+
// --- combined ---
1595+
it('fixes multiple issues in one pass', () => {
1596+
const input = `{
1597+
// user record
1598+
name: 'Alice',
1599+
scores: [10, 20,],
1600+
}`
1601+
const { text, fixes } = repairJson(input)
1602+
expect(JSON.parse(text)).toEqual({ name: 'Alice', scores: [10, 20] })
1603+
expect(fixes.length).toBeGreaterThanOrEqual(3)
1604+
})
1605+
1606+
it('reports fixes array describing each repair', () => {
1607+
const { fixes } = repairJson('{a: 1,}')
1608+
expect(Array.isArray(fixes)).toBe(true)
1609+
expect(fixes.every(f => typeof f === 'string')).toBe(true)
1610+
})
1611+
})

0 commit comments

Comments
 (0)