I built a JSON diff tool in a single HTML file (no build step)
I kept needing to compare JSON payloads side-by-side. API response vs expected. Before vs after. Prod vs staging. Every time, I'd paste into some random online tool, squint at it, then do it again five minutes later.
So I built fknjsn.com — a local-first JSON comparison tool. The whole thing is one HTML file with no build step.
The stack (or lack thereof)
- Vue 3 via CDN (global build)
- vue-json-pretty via CDN for the tree rendering
- localStorage for persistence
- That's it
No webpack. No vite. No node_modules. No package.json. Just a single index.html you could email to someone.
Why no build step?
Mostly stubbornness. I wanted to see how far I could get before reaching for tooling.
Turns out: pretty far. The setup is just two script tags and a destructure:
<script src="https://unpkg.com/vue@3.4.21/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/vue-json-pretty@2.4.0/lib/vue-json-pretty.js"></script>
<script>
const { createApp, ref, computed, watch, onMounted, nextTick } = Vue;
// ... the rest is just Vue
</script>
Global builds aren't fashionable, but they work. No bundler required, no import maps to configure, just script tags like it's 2014 — except now you get a reactive framework.
The interesting bits
Paste-anywhere UX: The app listens for paste events globally, but ignores them when you're focused on an input. So you can paste JSON anywhere on the page and it lands in the selected row.
window.addEventListener('paste', (e) => {
const tag = document.activeElement?.tagName?.toLowerCase()
if (tag === 'input' || tag === 'textarea') return
try {
const json = JSON.parse(e.clipboardData.getData('text'))
addJsonToSelectedRow(json)
} catch {
// not valid JSON, ignore
}
})
Recursive search: Each JSON block has its own search input. The filter walks the tree and keeps parent nodes if any descendant matches. The actual implementation is more verbose, but conceptually:
function filterJson(obj, search) {
if (!search) return obj
const lower = search.toLowerCase()
if (Array.isArray(obj)) {
const filtered = obj
.map(item => filterJson(item, search))
.filter(item => item !== undefined)
return filtered.length ? filtered : undefined
}
if (obj && typeof obj === 'object') {
const result = {}
for (const [key, value] of Object.entries(obj)) {
if (key.toLowerCase().includes(lower)) {
result[key] = value
} else {
const filtered = filterJson(value, search)
if (filtered !== undefined) result[key] = filtered
}
}
return Object.keys(result).length ? result : undefined
}
// primitives
const str = String(obj).toLowerCase()
return str.includes(lower) ? obj : undefined
}
Debounced persistence: State saves to localStorage, but debounced at 500ms so rapid changes don't hammer storage:
watch(rows, () => {
clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
localStorage.setItem('json-rows', JSON.stringify(state))
}, 500)
}, { deep: true })
What I'd do differently
If this needed to scale (bigger JSON, more features), I'd probably:
- Add a web worker for filtering large payloads
- Use virtual scrolling for the tree view
- Actually add a diff view instead of just side-by-side
But for now it does what I need, and the whole thing fits in my head.
Try it
Source is view-source — it's all right there.
The name is pronounced how you think it is.
Top comments (0)