Skip to content

Commit 782a587

Browse files
committed
added EscapeJSON and JsonMerge functionalities
1 parent 4ad6ef1 commit 782a587

4 files changed

Lines changed: 321 additions & 1 deletion

File tree

src/core/App.js

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ const tools = [
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' },
1010
{ id: 'repair-json', name: 'Repair JSON', desc: 'Fix trailing commas, single quotes, unquoted keys and comments', icon: '⚙', accent: '#f43f5e', category: 'essentials' },
11+
{ id: 'escape-json', name: 'Escape / Unescape', desc: 'Escape a string for use inside JSON, or unescape it back', icon: '⇔', accent: '#0ea5e9', category: 'essentials' },
1112
// Convert
1213
{ id: 'json-to-csv', name: 'JSON to CSV', desc: 'Convert JSON arrays to CSV spreadsheet format', icon: '⇢', accent: '#10b981', category: 'convert' },
1314
{ id: 'csv-to-json', name: 'CSV to JSON', desc: 'Convert CSV data to a JSON array', icon: '⇠', accent: '#f59e0b', category: 'convert' },
1415
{ id: 'json-to-yaml', name: 'JSON to YAML', desc: 'Convert JSON to human-readable YAML format', icon: '⟿', accent: '#ef4444', category: 'convert' },
1516
{ id: 'yaml-to-json', name: 'YAML to JSON', desc: 'Convert YAML configuration back to JSON', icon: '⟻', accent: '#ec4899', category: 'convert' },
1617
// Analyze & Transform
18+
{ id: 'json-merge', name: 'JSON Merge', desc: 'Deep merge two JSON objects, with B values taking precedence', icon: '⊞', accent: '#6366f1', category: 'transform' },
1719
{ id: 'json-diff', name: 'JSON Diff', desc: 'Compare two JSON objects and highlight differences', icon: '⊕', accent: '#f97316', category: 'transform' },
1820
{ id: 'json-schema', name: 'Schema Validator', desc: 'Validate JSON against a JSON Schema definition', icon: '✓', accent: '#84cc16', category: 'transform' },
1921
{ id: 'flatten-json', name: 'Flatten JSON', desc: 'Flatten nested objects to dot-notation keys', icon: '⬇', accent: '#14b8a6', category: 'transform' },
@@ -74,7 +76,7 @@ export default class App {
7476
<span>Browser-based · Zero uploads</span>
7577
</div>
7678
<h1 class="hero-title">JSON <span>Kit</span></h1>
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>
79+
<p class="hero-tagline">15 free JSON tools in your browser — format, convert, diff, query and more. Your data never leaves your device.</p>
7880
<div class="hero-props">
7981
<div class="hero-prop">
8082
<div class="hero-prop-icon">🔒</div>
@@ -187,12 +189,14 @@ export default class App {
187189
${this.csvToJsonViewHTML()}
188190
${this.jsonToYamlViewHTML()}
189191
${this.yamlToJsonViewHTML()}
192+
${this.jsonMergeViewHTML()}
190193
${this.jsonDiffViewHTML()}
191194
${this.jsonSchemaViewHTML()}
192195
${this.flattenJsonViewHTML()}
193196
${this.unflattenJsonViewHTML()}
194197
${this.jsonQueryViewHTML()}
195198
${this.repairJsonViewHTML()}
199+
${this.escapeJsonViewHTML()}
196200
`
197201
}
198202

@@ -485,6 +489,60 @@ export default class App {
485489
`
486490
}
487491

492+
jsonMergeViewHTML() {
493+
const t = tools.find(x => x.id === 'json-merge')
494+
return `
495+
<div id="view-json-merge" class="tool-view" role="main">
496+
${this.toolHeaderHTML(t)}
497+
<div class="editor-layout">
498+
<div class="editor-pane">
499+
<div class="editor-pane-header">
500+
<span class="pane-label">JSON A (base)</span>
501+
<div class="pane-actions">
502+
<button class="btn btn-ghost btn-sm" id="merge-clear">Clear both</button>
503+
</div>
504+
</div>
505+
<div class="editor-pane-body">
506+
<textarea id="merge-a" class="code-area" placeholder='{"name":"Alice","role":"admin","settings":{"theme":"dark"}}' spellcheck="false" autocomplete="off"></textarea>
507+
</div>
508+
</div>
509+
510+
<div class="editor-pane">
511+
<div class="editor-pane-header">
512+
<span class="pane-label">JSON B (overrides)</span>
513+
</div>
514+
<div class="editor-pane-body">
515+
<textarea id="merge-b" class="code-area" placeholder='{"role":"user","settings":{"lang":"en"},"email":"alice@example.com"}' spellcheck="false" autocomplete="off"></textarea>
516+
</div>
517+
</div>
518+
</div>
519+
520+
<div class="editor-pane" style="margin-top:1rem;min-height:260px;">
521+
<div class="editor-pane-header">
522+
<span class="pane-label">Merged Output</span>
523+
<div class="pane-actions">
524+
<select id="merge-strategy" class="btn btn-ghost btn-sm" style="cursor:pointer;">
525+
<option value="deep">Deep merge</option>
526+
<option value="shallow">Shallow merge</option>
527+
</select>
528+
<select id="merge-arrays" class="btn btn-ghost btn-sm" style="cursor:pointer;">
529+
<option value="replace">Arrays: replace</option>
530+
<option value="concat">Arrays: concat</option>
531+
</select>
532+
<button class="btn btn-primary btn-sm" id="merge-btn">Merge</button>
533+
<button class="btn btn-secondary btn-sm" id="merge-copy">Copy</button>
534+
<button class="btn btn-secondary btn-sm" id="merge-download">Download</button>
535+
</div>
536+
</div>
537+
<div class="editor-pane-body">
538+
<div id="merge-output" class="output-area empty">Merged JSON will appear here…</div>
539+
</div>
540+
<div class="status-message" id="merge-status"></div>
541+
</div>
542+
</div>
543+
`
544+
}
545+
488546
jsonDiffViewHTML() {
489547
const t = tools.find(x => x.id === 'json-diff')
490548
return `
@@ -721,6 +779,47 @@ export default class App {
721779
`
722780
}
723781

782+
escapeJsonViewHTML() {
783+
const t = tools.find(x => x.id === 'escape-json')
784+
return `
785+
<div id="view-escape-json" class="tool-view" role="main">
786+
${this.toolHeaderHTML(t)}
787+
<div class="editor-layout">
788+
<div class="editor-pane">
789+
<div class="editor-pane-header">
790+
<span class="pane-label">Input</span>
791+
<div class="pane-actions">
792+
<button class="btn btn-ghost btn-sm" id="escape-clear">Clear</button>
793+
</div>
794+
</div>
795+
<div class="editor-pane-body">
796+
<textarea id="escape-input" class="code-area" placeholder='Paste raw text to escape, or an escaped JSON string to unescape…' spellcheck="false" autocomplete="off"></textarea>
797+
</div>
798+
<div class="action-bar">
799+
<button class="btn btn-primary" id="escape-btn">Escape →</button>
800+
<button class="btn btn-secondary" id="unescape-btn">← Unescape</button>
801+
<div class="stats-bar" id="escape-input-stats"><span>0 chars</span></div>
802+
</div>
803+
</div>
804+
805+
<div class="editor-pane">
806+
<div class="editor-pane-header">
807+
<span class="pane-label">Output</span>
808+
<div class="pane-actions">
809+
<button class="btn btn-secondary btn-sm" id="escape-copy">Copy</button>
810+
<button class="btn btn-secondary btn-sm" id="escape-download">Download</button>
811+
</div>
812+
</div>
813+
<div class="editor-pane-body">
814+
<div id="escape-output" class="output-area empty">Result will appear here…</div>
815+
</div>
816+
<div class="status-message" id="escape-status"></div>
817+
</div>
818+
</div>
819+
</div>
820+
`
821+
}
822+
724823
repairJsonViewHTML() {
725824
const t = tools.find(x => x.id === 'repair-json')
726825
return `

src/main.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ 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'
2727
import { init as initRepairJson } from './tools/RepairJson.js'
28+
import { init as initEscapeJson } from './tools/EscapeJson.js'
29+
import { init as initJsonMerge } from './tools/JsonMerge.js'
2830

2931
document.addEventListener('DOMContentLoaded', () => {
3032
// Boot the SPA shell
@@ -44,6 +46,7 @@ document.addEventListener('DOMContentLoaded', () => {
4446
initUnflattenJson()
4547
initJsonQuery()
4648
initRepairJson()
49+
initJsonMerge()
4750

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

src/tools/EscapeJson.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/* ============================================
2+
EscapeJson.js — Escape / Unescape JSON strings
3+
============================================ */
4+
5+
import { copyToClipboard, downloadText, countStats } from '../core/Utils.js'
6+
7+
export function init() {
8+
const input = document.getElementById('escape-input')
9+
const output = document.getElementById('escape-output')
10+
const status = document.getElementById('escape-status')
11+
const stats = document.getElementById('escape-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('escape-btn').addEventListener('click', runEscape)
21+
document.getElementById('unescape-btn').addEventListener('click', runUnescape)
22+
23+
input.addEventListener('keydown', (e) => {
24+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') runEscape()
25+
})
26+
27+
document.getElementById('escape-clear').addEventListener('click', () => {
28+
input.value = ''
29+
output.textContent = ''
30+
output.className = 'output-area empty'
31+
output.dataset.raw = ''
32+
setStatus('', '')
33+
stats.innerHTML = '<span>0 chars</span>'
34+
})
35+
36+
document.getElementById('escape-copy').addEventListener('click', () => {
37+
const text = output.dataset.raw
38+
if (text) copyToClipboard(text)
39+
})
40+
41+
document.getElementById('escape-download').addEventListener('click', () => {
42+
const text = output.dataset.raw
43+
if (text) downloadText(text, 'escaped.json', 'application/json')
44+
})
45+
46+
function runEscape() {
47+
const raw = input.value
48+
if (!raw) {
49+
setStatus('Paste text to escape.', 'info')
50+
return
51+
}
52+
const result = escapeJson(raw)
53+
output.textContent = result
54+
output.className = 'output-area'
55+
output.dataset.raw = result
56+
const chars = raw.length
57+
setStatus(`✓ Escaped · ${chars.toLocaleString()} chars → ${result.length.toLocaleString()} chars`, 'success')
58+
}
59+
60+
function runUnescape() {
61+
const raw = input.value.trim()
62+
if (!raw) {
63+
setStatus('Paste an escaped JSON string to unescape.', 'info')
64+
return
65+
}
66+
try {
67+
const result = unescapeJson(raw)
68+
output.textContent = result
69+
output.className = 'output-area'
70+
output.dataset.raw = result
71+
setStatus(`✓ Unescaped · ${result.length.toLocaleString()} chars`, 'success')
72+
} catch (e) {
73+
output.textContent = ''
74+
output.className = 'output-area empty'
75+
output.dataset.raw = ''
76+
setStatus(`✕ Invalid escaped string: ${e.message.split(' at')[0]}`, 'error')
77+
}
78+
}
79+
80+
function setStatus(msg, type) {
81+
status.textContent = msg
82+
status.className = `status-message${msg ? ' show ' + type : ''}`
83+
}
84+
}
85+
86+
/**
87+
* Escape a raw string into a JSON string literal (with surrounding quotes).
88+
* Handles: ", \, newlines, tabs, carriage returns, and control characters.
89+
* @param {string} raw
90+
* @returns {string}
91+
*/
92+
export function escapeJson(raw) {
93+
return JSON.stringify(raw)
94+
}
95+
96+
/**
97+
* Unescape a JSON string literal back to a plain string.
98+
* Accepts with or without surrounding double quotes.
99+
* @param {string} escaped
100+
* @returns {string}
101+
*/
102+
export function unescapeJson(escaped) {
103+
const s = escaped.trim()
104+
if (s.startsWith('"') && s.endsWith('"') && s.length >= 2) {
105+
return JSON.parse(s)
106+
}
107+
// Wrap bare escaped content in quotes and parse
108+
return JSON.parse(`"${s}"`)
109+
}

src/tools/JsonMerge.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/* ============================================
2+
JsonMerge.js — Deep merge two JSON objects
3+
============================================ */
4+
5+
import { copyToClipboard, downloadText } from '../core/Utils.js'
6+
7+
export function init() {
8+
const inputA = document.getElementById('merge-a')
9+
const inputB = document.getElementById('merge-b')
10+
const output = document.getElementById('merge-output')
11+
const status = document.getElementById('merge-status')
12+
13+
if (!inputA) return
14+
15+
document.getElementById('merge-btn').addEventListener('click', runMerge)
16+
17+
;[inputA, inputB].forEach(ta => {
18+
ta.addEventListener('keydown', (e) => {
19+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') runMerge()
20+
})
21+
})
22+
23+
document.getElementById('merge-clear').addEventListener('click', () => {
24+
inputA.value = ''
25+
inputB.value = ''
26+
output.textContent = ''
27+
output.className = 'output-area empty'
28+
output.dataset.raw = ''
29+
setStatus('', '')
30+
})
31+
32+
document.getElementById('merge-copy').addEventListener('click', () => {
33+
const text = output.dataset.raw
34+
if (text) copyToClipboard(text)
35+
})
36+
37+
document.getElementById('merge-download').addEventListener('click', () => {
38+
const text = output.dataset.raw
39+
if (text) downloadText(text, 'merged.json', 'application/json')
40+
})
41+
42+
function runMerge() {
43+
const rawA = inputA.value.trim()
44+
const rawB = inputB.value.trim()
45+
46+
if (!rawA || !rawB) {
47+
setStatus('Both JSON inputs are required.', 'error')
48+
return
49+
}
50+
51+
let a, b
52+
try { a = JSON.parse(rawA) } catch (e) { setStatus(`JSON A parse error: ${e.message.split(' at')[0]}`, 'error'); return }
53+
try { b = JSON.parse(rawB) } catch (e) { setStatus(`JSON B parse error: ${e.message.split(' at')[0]}`, 'error'); return }
54+
55+
const strategy = document.getElementById('merge-strategy').value
56+
const arrayMode = document.getElementById('merge-arrays').value
57+
58+
let result
59+
if (strategy === 'deep') {
60+
result = deepMerge(a, b, arrayMode)
61+
} else {
62+
result = Object.assign({}, a, b)
63+
}
64+
65+
const pretty = JSON.stringify(result, null, 2)
66+
output.textContent = pretty
67+
output.className = 'output-area'
68+
output.dataset.raw = pretty
69+
setStatus('✓ Merged successfully', 'success')
70+
}
71+
72+
function setStatus(msg, type) {
73+
status.textContent = msg
74+
status.className = `status-message${msg ? ' show ' + type : ''}`
75+
}
76+
}
77+
78+
/**
79+
* Deep merge two JSON values. For objects, keys from b are recursively merged
80+
* into a. For arrays, behaviour is controlled by arrayMode ('replace' or 'concat').
81+
* All other values (primitives, null) are overridden by b.
82+
* @param {*} a
83+
* @param {*} b
84+
* @param {'replace'|'concat'} arrayMode
85+
* @returns {*}
86+
*/
87+
export function deepMerge(a, b, arrayMode = 'replace') {
88+
if (isPlainObject(a) && isPlainObject(b)) {
89+
const result = Object.assign({}, a)
90+
for (const key of Object.keys(b)) {
91+
if (key in result) {
92+
result[key] = deepMerge(result[key], b[key], arrayMode)
93+
} else {
94+
result[key] = b[key]
95+
}
96+
}
97+
return result
98+
}
99+
100+
if (Array.isArray(a) && Array.isArray(b)) {
101+
return arrayMode === 'concat' ? [...a, ...b] : b
102+
}
103+
104+
return b
105+
}
106+
107+
function isPlainObject(val) {
108+
return val !== null && typeof val === 'object' && !Array.isArray(val)
109+
}

0 commit comments

Comments
 (0)