DEV Community

Cover image for I built a JSON diff tool in a single HTML file (no build step)
Jon Muller
Jon Muller

Posted on

I built a JSON diff tool in a single HTML file (no build step)

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>
Enter fullscreen mode Exit fullscreen mode

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
  }
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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

fknjsn.com

Source is view-source — it's all right there.


The name is pronounced how you think it is.

Top comments (0)