JSON Toolbox
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A paste-and-poke JSON workbench that opens on a corner bakery's storefront record: foldable tree, live size and node counts, and pretty/minify round-trips that keep key order and number formatting exactly as written. It runs a hand-written parser instead of JSON.parse, so a broken document gets one plain-English sentence and a caret under the exact character — "there is a comma just before this }" beats "Unexpected token }". Click any row to copy its path ($.menu[1].stock.onHand), or type bun to filter keys and values at once with every matching branch opened for you. Hit broken to watch the error panel work, and drop a .json file on the editor if you have one — nothing ever leaves the tab.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JSON Toolbox — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---- value-type palette (deliberately no green: green is earned) ---- */
:root {
--t-str: #1D4ED8; --t-str-bg: #E7EDFC;
--t-num: #A64B00; --t-num-bg: #FDEEDD;
--t-bool: #6D28D9; --t-bool-bg: #EEE7FB;
--t-null: #6B7280; --t-null-bg: #ECEBE7;
--t-obj: #0F766E; --t-obj-bg: #E1F0EE;
--t-arr: #B02A6B; --t-arr-bg: #FBE6F0;
--mark: rgba(192, 86, 0, .22);
--mark-edge: rgba(192, 86, 0, .60);
--accent-ink: #8F4100; /* accent as *text* on accent-soft: --accent itself is only 4.2:1 there */
--sel: #FFF3E6;
--danger-soft: #FBE9E8;
--warn: #8A5A00; --warn-soft: #FBF1DC;
--shadow: 0 1px 2px rgba(16, 18, 22, .05), 0 8px 24px -18px rgba(16, 18, 22, .35);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--t-str: #8AB4FF; --t-str-bg: #1B2942;
--t-num: #FFB454; --t-num-bg: #38290F;
--t-bool: #C4A2FF; --t-bool-bg: #2C2247;
--t-null: #8C939C; --t-null-bg: #262A32;
--t-obj: #56C8BC; --t-obj-bg: #14312E;
--t-arr: #F58AB9; --t-arr-bg: #3B1B2C;
--mark: rgba(255, 180, 84, .26);
--mark-edge: rgba(255, 180, 84, .70);
--accent-ink: #FFB454;
--sel: #2A2118;
--danger-soft: #3A1D1C;
--warn: #E0AE55; --warn-soft: #322817;
--shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 10px 30px -20px rgba(0, 0, 0, .9);
}
}
:root[data-theme="dark"] {
--t-str: #8AB4FF; --t-str-bg: #1B2942;
--t-num: #FFB454; --t-num-bg: #38290F;
--t-bool: #C4A2FF; --t-bool-bg: #2C2247;
--t-null: #8C939C; --t-null-bg: #262A32;
--t-obj: #56C8BC; --t-obj-bg: #14312E;
--t-arr: #F58AB9; --t-arr-bg: #3B1B2C;
--mark: rgba(255, 180, 84, .26);
--mark-edge: rgba(255, 180, 84, .70);
--accent-ink: #FFB454;
--sel: #2A2118;
--danger-soft: #3A1D1C;
--warn: #E0AE55; --warn-soft: #322817;
--shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 10px 30px -20px rgba(0, 0, 0, .9);
}
/* native chrome (textarea caret, select menu, scrollbars) has to follow the theme too —
without this a platform-forced dark theme on a light OS paints light scrollbars. */
:root { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; } }
:root[data-theme="dark"] { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; max-width: 100%; overflow-x: hidden; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; }
.wrap { max-width: 1240px; margin: 0 auto; padding: 22px 16px 36px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.mono { font-family: var(--font-mono); }
/* ---- header ---- */
header.top { margin-bottom: 14px; }
.titlerow { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
h1 { font-size: 1.4rem; margin: 0; letter-spacing: -0.015em; }
.lede { margin: 7px 0 10px; color: var(--muted); max-width: 68ch; font-size: .94rem; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
font-family: var(--font-mono); font-size: .69rem; letter-spacing: .02em;
padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
color: var(--muted); background: var(--surface); white-space: nowrap;
}
/* ---- controls ---- */
.bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px;
padding: 9px 10px; margin-bottom: 12px;
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow);
}
.grp { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.grp + .grp { margin-left: auto; }
.lbl {
font-family: var(--font-mono); font-size: .68rem; letter-spacing: .06em;
text-transform: uppercase; color: var(--muted); white-space: nowrap;
}
button, select, input { font: inherit; color: inherit; }
.btn {
font-family: var(--font-mono); font-size: .76rem;
padding: 6px 10px; border: 1px solid var(--line); border-radius: 7px;
background: var(--surface); color: var(--ink); cursor: pointer;
transition: background .12s ease, border-color .12s ease, color .12s ease;
white-space: nowrap;
}
.btn:hover { background: var(--accent-soft); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); }
.btn[disabled] { opacity: .45; cursor: not-allowed; }
.btn[disabled]:hover { background: var(--surface); border-color: var(--line); }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
.seg .btn { border: 0; border-radius: 0; }
.seg .btn + .btn { border-left: 1px solid var(--line); }
select {
font-family: var(--font-mono); font-size: .76rem; padding: 6px 8px;
border: 1px solid var(--line); border-radius: 7px; background: var(--surface); cursor: pointer;
}
/* ---- stat strip ---- */
.stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
gap: 1px; background: var(--line); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden; margin-bottom: 10px;
}
.stat { background: var(--surface); padding: 8px 10px; min-width: 0; }
.stat .k {
display: block; font-family: var(--font-mono); font-size: .64rem; letter-spacing: .07em;
text-transform: uppercase; color: var(--muted); margin-bottom: 2px; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.stat .v {
display: block; font-family: var(--font-mono); font-size: 1.02rem; font-weight: 600;
letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stat .v small { font-size: .68rem; font-weight: 500; color: var(--muted); margin-left: 3px; }
.stat.is-ok .v { color: var(--run); }
.stat.is-bad .v { color: var(--danger); }
/* ---- composition bar ---- */
.comp { margin-bottom: 12px; }
.compbar {
display: flex; height: 9px; border-radius: 999px; overflow: hidden;
border: 1px solid var(--line); background: var(--surface);
}
.compbar span { display: block; height: 100%; min-width: 2px; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 7px; }
.legend b {
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-mono); font-size: .69rem; font-weight: 500; color: var(--muted);
}
.legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
.legend em { font-style: normal; color: var(--ink); }
/* ---- panes ---- */
.panes { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: start; }
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); overflow: hidden; display: flex; flex-direction: column; min-width: 0;
}
.card > header {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 7px 10px; border-bottom: 1px solid var(--line); background: var(--bg);
}
.tag {
font-family: var(--font-mono); font-size: .69rem; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tag em { font-style: normal; color: var(--ink); }
.pill {
font-family: var(--font-mono); font-size: .68rem; padding: 2px 8px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--surface); white-space: nowrap;
}
.pill.ok { color: var(--run); background: var(--surface); border-color: var(--run); font-weight: 600; }
.pill.bad { color: var(--danger); background: var(--surface); border-color: var(--danger); font-weight: 600; }
.pill.warn { color: var(--warn); background: var(--surface); border-color: var(--warn); }
.spacer { margin-left: auto; }
/* ---- editor ---- */
:root { --paneh: clamp(300px, 46vh, 470px); }
.ed { position: relative; display: flex; height: var(--paneh); min-height: 0; background: var(--surface); }
.gutter {
position: relative; overflow: hidden; flex: 0 0 auto; height: 100%;
padding: 10px 8px 10px 10px; border-right: 1px solid var(--line);
background: var(--bg); user-select: none;
}
.gutter .gi {
font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6;
color: var(--muted); text-align: right; white-space: pre; will-change: transform;
}
.gutter .gi b { display: block; font-weight: 400; }
.gutter .gi b.bad { color: var(--danger); font-weight: 700; }
textarea#src {
flex: 1 1 auto; min-width: 0; display: block;
font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6;
padding: 10px; border: 0; resize: none; background: transparent; color: var(--ink);
height: 100%; tab-size: 2; white-space: pre; overflow: auto;
}
textarea#src:focus { outline: 0; }
.ed.focused { box-shadow: inset 0 0 0 2px var(--accent); }
.ed.drop { box-shadow: inset 0 0 0 2px var(--accent); background: var(--accent-soft); }
/* ---- error strip ---- */
.err {
border-top: 1px solid var(--line); background: var(--surface);
box-shadow: inset 3px 0 0 var(--danger); padding: 9px 10px 9px 13px;
}
.err .hd { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.err .where { font-family: var(--font-mono); font-size: .72rem; color: var(--danger); font-weight: 600; white-space: nowrap; }
.err .msg { font-size: .88rem; color: var(--ink); }
.err .hint { font-size: .82rem; color: var(--muted); margin-top: 3px; }
.err pre {
font-family: var(--font-mono); font-size: 12px; line-height: 1.5; margin: 7px 0 0;
padding: 7px 8px; background: var(--bg); border: 1px solid var(--line);
border-radius: 6px; overflow-x: auto; color: var(--ink); white-space: pre;
}
.err pre .ln { color: var(--muted); }
.err pre .caret { color: var(--danger); font-weight: 700; }
.err .acts { margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap; }
.note { border-top: 1px solid var(--line); background: var(--warn-soft); padding: 7px 10px; font-size: .82rem; color: var(--ink); }
.note code { font-family: var(--font-mono); font-size: .78rem; color: var(--warn); }
.hintbar {
border-top: 1px solid var(--line); padding: 7px 10px; font-size: .78rem; color: var(--muted);
background: var(--surface);
}
kbd {
font-family: var(--font-mono); font-size: .72rem; padding: 1px 5px; border: 1px solid var(--line);
border-bottom-width: 2px; border-radius: 4px; background: var(--bg); color: var(--ink); white-space: nowrap;
}
/* ---- search ---- */
.searchwrap { position: relative; flex: 1 1 150px; min-width: 110px; display: flex; }
.searchwrap input {
width: 100%; font-family: var(--font-mono); font-size: .78rem; padding: 5px 26px 5px 9px;
border: 1px solid var(--line); border-radius: 7px; background: var(--surface); color: var(--ink);
}
.searchwrap input::placeholder { color: var(--muted); opacity: 1; }
.searchwrap input::-webkit-search-cancel-button,
.searchwrap input::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
.xbtn {
position: absolute; right: 3px; top: 50%; transform: translateY(-50%);
width: 20px; height: 20px; line-height: 1; border: 0; border-radius: 50%;
background: transparent; color: var(--muted); cursor: pointer; font-size: 15px;
}
.xbtn:hover { color: var(--ink); background: var(--accent-soft); }
/* ---- tree ---- */
.tree {
height: var(--paneh); overflow: auto; padding: 6px 0 8px;
font-family: var(--font-mono); font-size: 12.5px; line-height: 1.55;
}
.tree.stale { opacity: .55; }
.row {
display: flex; align-items: center; gap: 0; padding: 1px 12px 1px 0;
cursor: pointer; border: 1px solid transparent; border-left: 0; border-right: 0;
scroll-margin: 30px; min-width: min-content; white-space: nowrap; flex-wrap: nowrap;
}
.row > * { flex: 0 0 auto; }
.row:hover { background: var(--accent-soft); }
.row[aria-selected="true"] { background: var(--sel); border-color: var(--accent); }
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 0; }
.tw {
position: relative; flex: 0 0 auto; width: 16px; height: 19px; margin-right: 2px; padding: 0;
border: 0; background: transparent; color: var(--muted); cursor: pointer;
font-size: 10px; line-height: 19px; text-align: center; border-radius: 3px;
}
/* widen the fold target without moving anything; on touch the rows grow too */
.tw::after { content: ''; position: absolute; left: -6px; right: -6px; top: 0; bottom: 0; }
@media (pointer: coarse) {
.row { padding-top: 3px; padding-bottom: 3px; }
.tw { height: 25px; line-height: 25px; }
}
.tw:hover { color: var(--accent-ink); background: var(--accent-soft); }
.tw svg { width: 9px; height: 9px; transition: transform .12s ease; vertical-align: middle; }
.row.open > .tw svg { transform: rotate(90deg); }
.tw.blank { visibility: hidden; }
.k { color: var(--ink); font-weight: 600; white-space: pre; }
.idx { color: var(--muted); white-space: pre; }
.colon { color: var(--muted); margin-right: 5px; }
.v { white-space: pre; overflow-wrap: normal; }
.v.s { color: var(--t-str); }
.v.n { color: var(--t-num); }
.v.b { color: var(--t-bool); }
.v.z { color: var(--t-null); font-style: italic; }
.v.o, .v.a { color: var(--muted); }
.count {
font-size: .66rem; padding: 0 5px; margin-left: 6px; border-radius: 999px;
white-space: nowrap; vertical-align: 1px;
}
.count.o { color: var(--t-obj); background: var(--t-obj-bg); }
.count.a { color: var(--t-arr); background: var(--t-arr-bg); }
.prev { color: var(--muted); margin-left: 6px; }
.copyhint {
margin-left: 8px; font-size: .66rem; color: var(--accent-ink); opacity: 0;
white-space: nowrap; flex: 0 0 auto;
}
.row:hover .copyhint, .row:focus-visible .copyhint { opacity: 1; }
mark { background: var(--mark); color: inherit; box-shadow: inset 0 -1px 0 var(--mark-edge); border-radius: 2px; padding: 0 1px; }
.empty { padding: 22px 14px; color: var(--muted); font-size: .84rem; font-family: var(--font-sans); text-align: center; }
.more { padding: 8px 14px; color: var(--muted); font-size: .72rem; }
/* ---- path bar ---- */
.pathbar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--line); background: var(--bg); flex-wrap: wrap; }
.pathbar input {
flex: 1 1 130px; min-width: 90px; font-family: var(--font-mono); font-size: .76rem;
padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px;
background: var(--surface); color: var(--ink);
}
.flash { font-family: var(--font-mono); font-size: .7rem; padding: 3px 8px; border-radius: 999px; white-space: nowrap; }
.flash { border: 1px solid transparent; font-weight: 600; }
.flash.ok { color: var(--run); background: var(--surface); border-color: var(--run); }
.flash.bad { color: var(--danger); background: var(--surface); border-color: var(--danger); }
footer { margin-top: 20px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); text-align: center; }
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (max-width: 900px) {
.panes { grid-template-columns: minmax(0, 1fr); }
.grp + .grp { margin-left: 0; }
:root { --paneh: clamp(260px, 40vh, 400px); }
}
@media (max-width: 480px) {
.wrap { padding: 16px 10px 26px; }
h1 { font-size: 1.2rem; }
.btn { padding: 6px 8px; font-size: .72rem; }
.stats { grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); }
.stat .v { font-size: .92rem; }
.tree, textarea#src, .gutter .gi { font-size: 12px; }
.copyhint, .prev { display: none; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
.btn:active { transform: none; }
}
</style>
</head>
<body>
<main class="wrap">
<header class="top">
<div class="titlerow">
<h1>JSON Toolbox</h1>
<span class="pill" id="verdict">checking…</span>
</div>
<p class="lede">
Paste JSON on the left and it parses as you type: a foldable tree on the right,
live size and node counts, one-click path copying, and search across every key and value.
Broken JSON gets a plain sentence and a caret pointing at the exact character.
</p>
<div class="chips">
<span class="chip">hand-written parser</span>
<span class="chip">nothing leaves this tab</span>
<span class="chip">zero dependencies</span>
</div>
</header>
<div class="bar">
<div class="grp">
<span class="lbl">format</span>
<div class="seg" role="group" aria-label="Formatting">
<button class="btn" id="btnPretty" type="button" aria-pressed="false">pretty</button>
<button class="btn" id="btnMinify" type="button" aria-pressed="false">minify</button>
</div>
<label class="lbl" for="indent">indent</label>
<select id="indent">
<option value="2" selected>2 spaces</option>
<option value="4">4 spaces</option>
<option value="tab">tab</option>
</select>
</div>
<div class="grp">
<span class="lbl">sample</span>
<button class="btn" id="s-bakery" type="button">bakery</button>
<button class="btn" id="s-playlist" type="button">playlist</button>
<button class="btn" id="s-garden" type="button">greenhouse</button>
<button class="btn" id="s-broken" type="button">broken</button>
<button class="btn" id="btnCopyAll" type="button">copy all</button>
</div>
</div>
<div class="stats" id="stats"></div>
<div class="comp">
<div class="compbar" id="compbar" role="img" aria-label="Share of values by type"></div>
<div class="legend" id="legend"></div>
</div>
<div class="panes">
<section class="card" aria-labelledby="lbl-in">
<header>
<span class="tag" id="lbl-in">input · <em id="lineCount">0 lines</em></span>
<span class="pill spacer" id="fmtState">—</span>
</header>
<div class="ed" id="ed">
<div class="gutter" id="gutter" aria-hidden="true"><div class="gi" id="gutterInner"></div></div>
<textarea id="src" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
aria-label="JSON source text" aria-describedby="edHint"></textarea>
</div>
<div class="err" id="err" hidden></div>
<div class="note" id="dupes" hidden></div>
<p class="hintbar" id="edHint" style="margin:0">
Edits parse live. Drop a <kbd>.json</kbd> file on this panel to load it.
</p>
</section>
<section class="card" aria-labelledby="lbl-tree">
<header>
<span class="sr" id="lbl-tree">JSON tree</span>
<div class="searchwrap">
<input id="q" type="search" placeholder="search keys + values…" aria-label="Search keys and values" autocomplete="off">
<button class="xbtn" id="qClear" type="button" aria-label="Clear search" hidden>×</button>
</div>
<button class="btn" id="btnExpand" type="button">expand</button>
<button class="btn" id="btnCollapse" type="button">collapse</button>
<span class="pill" id="qState" hidden></span>
</header>
<div class="tree" id="tree" role="tree" aria-label="JSON structure"></div>
<div class="pathbar">
<span class="lbl">path</span>
<input id="path" readonly value="$" aria-label="Path of the selected row">
<button class="btn" id="btnCopyPath" type="button">copy path</button>
<button class="btn" id="btnCopyVal" type="button">copy value</button>
<span class="flash" id="flash" hidden></span>
</div>
<p class="hintbar" style="margin:0">
Click any row to copy its path. Keys: <kbd>↑</kbd><kbd>↓</kbd> move,
<kbd>→</kbd><kbd>←</kbd> fold, <kbd>Enter</kbd> copy path, <kbd>v</kbd> copy value.
</p>
</section>
</div>
<p class="sr" role="status" aria-live="polite" id="live"></p>
<footer>demo · sloprun.dev</footer>
</main>
<script>
(function () {
'use strict';
/* ---------------- platform theme bridge (standalone works via prefers-color-scheme) ------- */
window.addEventListener('message', function (e) {
var d = e && e.data;
if (d && d.type === 'sloprun:theme' && (d.theme === 'light' || d.theme === 'dark')) {
document.documentElement.setAttribute('data-theme', d.theme);
}
});
var $ = function (id) { return document.getElementById(id); };
var el = {
src: $('src'), gutter: $('gutter'), gi: $('gutterInner'), ed: $('ed'),
err: $('err'), dupes: $('dupes'), tree: $('tree'), stats: $('stats'),
compbar: $('compbar'), legend: $('legend'), q: $('q'), qClear: $('qClear'),
path: $('path'), flash: $('flash'), live: $('live'), verdict: $('verdict'),
fmtState: $('fmtState'), lineCount: $('lineCount'), indent: $('indent')
};
/* ---------------- samples ---------------- */
var SAMPLES = {};
SAMPLES.bakery = [
'{',
' "shop": {',
' "name": "Rye & Ramble",',
' "opened": 2016,',
' "neighbourhood": "Elm Street",',
' "wifi": true,',
' "phone": null,',
' "hours": {',
' "mon": "07:00-15:00",',
' "tue": "07:00-15:00",',
' "wed": "07:00-15:00",',
' "thu": "07:00-17:00",',
' "fri": "07:00-18:00",',
' "sat": "08:00-18:00",',
' "sun": "closed"',
' },',
' "tags": ["bakery", "coffee", "dog friendly", "cash or card"]',
' },',
' "menu": [',
' {',
' "id": "sourdough-large",',
' "name": "Country sourdough",',
' "price": 6.5,',
' "vegan": true,',
' "allergens": ["wheat"],',
' "stock": { "onHand": 12, "unit": "loaves", "bakedAt": "05:40" },',
' "ratings": [5, 4.5, 5, 4, 5]',
' },',
' {',
' "id": "cardamom-bun",',
' "name": "Cardamom bun",',
' "price": 3.75,',
' "vegan": false,',
' "allergens": ["wheat", "milk", "egg"],',
' "stock": { "onHand": 0, "unit": "buns", "bakedAt": "06:15" },',
' "ratings": [5, 5, 5, 4.5]',
' },',
' {',
' "id": "rye-sandwich",',
' "name": "Rye & cheese sandwich",',
' "price": 8,',
' "vegan": false,',
' "allergens": ["rye", "milk"],',
' "stock": { "onHand": 5, "unit": "boxes", "bakedAt": "07:10" },',
' "ratings": [4, 4.5, 3.5]',
' },',
' {',
' "id": "oat-cookie",',
' "name": "Oat & raisin cookie",',
' "price": 2.25,',
' "vegan": true,',
' "allergens": ["oats"],',
' "stock": { "onHand": 34, "unit": "cookies", "bakedAt": "06:50" },',
' "ratings": []',
' }',
' ],',
' "orders": [',
' {',
' "ref": "A-1042",',
' "placedAt": "2024-05-14T08:12:00Z",',
' "items": [',
' { "sku": "sourdough-large", "qty": 1 },',
' { "sku": "oat-cookie", "qty": 3 }',
' ],',
' "total": 13.25,',
' "paid": true,',
' "note": "slice it, please"',
' },',
' {',
' "ref": "A-1043",',
' "placedAt": "2024-05-14T08:31:00Z",',
' "items": [{ "sku": "cardamom-bun", "qty": 6 }],',
' "total": 22.5,',
' "paid": false,',
' "note": null',
' }',
' ],',
' "today": {',
' "loavesBaked": 214,',
' "wasteKg": 1.8,',
' "busiestHour": "08:00",',
' "specials": { "morning": "cinnamon knot", "afternoon": null }',
' }',
'}'
].join('\n');
SAMPLES.playlist = [
'{',
' "playlist": "Kitchen table, 7am",',
' "shuffle": false,',
' "minutes": 42.6,',
' "owner": { "name": "Nadia", "since": 2011, "public": true },',
' "tracks": [',
' {',
' "title": "Paper Boats",',
' "artist": "Halfway Home",',
' "seconds": 214,',
' "year": 2019,',
' "moods": ["calm", "warm"],',
' "plays": { "week": 12, "total": 388 },',
' "liked": true',
' },',
' {',
' "title": "Slow Kettle",',
' "artist": "Marta Fen",',
' "seconds": 187,',
' "year": 2021,',
' "moods": ["calm"],',
' "plays": { "week": 3, "total": 51 },',
' "liked": false',
' },',
' {',
' "title": "Six Blue Windows",',
' "artist": "The Longer Way",',
' "seconds": 305,',
' "year": 2015,',
' "moods": ["dreamy", "warm", "rainy"],',
' "plays": { "week": 27, "total": 1204 },',
' "liked": true',
' },',
' {',
' "title": "Almost Awake",',
' "artist": "Halfway Home",',
' "seconds": 241,',
' "year": 2019,',
' "moods": [],',
' "plays": { "week": 0, "total": 9 },',
' "liked": null',
' }',
' ],',
' "lastPlayed": { "track": 2, "at": "2024-03-02T07:05:11Z", "device": "kitchen speaker" }',
'}'
].join('\n');
SAMPLES.garden = [
'{',
' "greenhouse": "Back garden, bed 3",',
' "sensor": { "id": "gh-03", "battery": 0.62, "online": true, "firmware": "2.4.1" },',
' "readings": [',
' { "at": "06:00", "tempC": 14.2, "humidity": 0.81, "soil": 0.44, "lightLux": 120 },',
' { "at": "09:00", "tempC": 19.7, "humidity": 0.68, "soil": 0.41, "lightLux": 9800 },',
' { "at": "12:00", "tempC": 26.9, "humidity": 0.52, "soil": 0.33, "lightLux": 41200 },',
' { "at": "15:00", "tempC": 25.1, "humidity": 0.57, "soil": 0.29, "lightLux": 26400 },',
' { "at": "18:00", "tempC": 18.4, "humidity": 0.72, "soil": 0.27, "lightLux": 3100 }',
' ],',
' "plants": [',
' { "name": "Tomato", "variety": "Gardener\'s Delight", "planted": "2024-03-19", "fruiting": true, "notes": ["needs tying", "side shoots removed"] },',
' { "name": "Basil", "variety": "Genovese", "planted": "2024-04-02", "fruiting": false, "notes": [] },',
' { "name": "Chilli", "variety": "Padron", "planted": "2024-03-28", "fruiting": true, "notes": ["first pick next week"] }',
' ],',
' "watering": { "auto": true, "litresPerDay": 2.5, "lastRun": "18:30", "nextRun": null },',
' "alerts": []',
'}'
].join('\n');
SAMPLES.broken = [
'{',
' "shop": "Rye & Ramble",',
' "open": true,',
' "menu": [',
' { "name": "Country sourdough", "price": 6.50 },',
' { "name": "Cardamom bun", "price": 3.75, },',
' { "name": "Oat cookie", "price": 2.25 }',
' ],',
' "note": \'ask about day-old bread\'',
'}'
].join('\n');
/* ---------------- the parser ----------------
Hand-written so every failure can be explained in a plain sentence with an
exact line, column and caret. Produces a node tree that keeps key order and
the raw text of every number. ------------------------------------------- */
function ParseError(msg, index, hint) {
this.name = 'ParseError'; this.message = msg; this.index = index; this.hint = hint || '';
}
ParseError.prototype = Object.create(Error.prototype);
function parse(text) {
var i = 0, N = text.length;
var lineStarts = [0], c;
for (var k = 0; k < N; k++) { if (text.charCodeAt(k) === 10) lineStarts.push(k + 1); }
function lineOf(idx) {
var lo = 0, hi = lineStarts.length - 1;
while (lo < hi) { var mid = (lo + hi + 1) >> 1; if (lineStarts[mid] <= idx) lo = mid; else hi = mid - 1; }
return lo;
}
function fail(idx, msg, hint) {
if (idx > N) idx = N;
var e = new ParseError(msg, idx, hint);
var ln = lineOf(idx);
e.line = ln + 1;
e.col = idx - lineStarts[ln] + 1;
var end = (ln + 1 < lineStarts.length) ? lineStarts[ln + 1] - 1 : N;
e.lineText = text.slice(lineStarts[ln], end).replace(/\r$/, '');
throw e;
}
function show(idx) {
if (idx >= N) return 'the end of the text';
var ch = text[idx];
if (ch === '\n') return 'a line break';
if (ch === '\r') return 'a carriage return';
if (ch === '\t') return 'a tab';
if (ch === ' ') return 'a space';
if (ch === '') return 'an invisible byte-order mark';
if (text.charCodeAt(idx) < 32) return 'an invisible control character';
return '`' + ch + '`';
}
function ws() {
while (i < N) {
c = text.charCodeAt(i);
if (c === 32 || c === 9 || c === 10 || c === 13) { i++; continue; }
if (c === 47) { /* / */
fail(i, 'JSON does not allow comments.',
'Delete the ' + (text[i + 1] === '*' ? '/* … */ block' : '// line') + ' — comments are a JavaScript feature, not a JSON one.');
}
break;
}
}
function parseString(what) {
var start = i;
i++; /* opening quote */
var out = '';
while (true) {
if (i >= N) fail(start, 'This ' + what + ' never closes.', 'Add a closing double quote at the end of the text that starts here.');
var ch = text[i];
if (ch === '"') { i++; break; }
if (ch === '\n') fail(i, 'A ' + what + ' cannot run onto the next line.', 'Close the quote before the line break, or write the break as \\n inside the text.');
if (ch === '\\') {
var esc = text[i + 1];
if (esc === undefined) fail(i, 'The text ends in the middle of an escape.', 'A backslash must be followed by one of " \\\\ / b f n r t or u.');
if ('"\\/bfnrt'.indexOf(esc) >= 0) {
out += ({ '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' })[esc];
i += 2; continue;
}
if (esc === 'u') {
var hex = text.slice(i + 2, i + 6);
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail(i, 'Broken \\u escape.', 'A \\u escape needs exactly four hex digits, like \\u00e9.');
out += String.fromCharCode(parseInt(hex, 16));
i += 6; continue;
}
fail(i + 1, 'Unknown escape `\\' + esc + '`.', 'Inside JSON text the allowed escapes are \\" \\\\ \\/ \\b \\f \\n \\r \\t and \\uXXXX.');
}
if (text.charCodeAt(i) < 32) fail(i, 'A control character sits inside this ' + what + '.', 'Write tabs and line breaks as \\t and \\n instead of pasting them raw.');
out += ch; i++;
}
return { type: 'string', value: out, raw: text.slice(start, i), start: start, end: i, children: null };
}
function parseNumber() {
var start = i;
if (text[i] === '-') i++;
var digitsStart = i;
while (i < N && text[i] >= '0' && text[i] <= '9') i++;
if (i === digitsStart) fail(i, 'A minus sign with no number after it.', 'Write something like -12 or -0.5.');
if (text[digitsStart] === '0' && i - digitsStart > 1) {
var lz = text.slice(digitsStart, i), lzFixed = lz.replace(/^0+/, '') || '0';
fail(digitsStart, 'Numbers cannot have extra leading zeros.', 'Write ' + lzFixed + ' instead of ' + lz + '.');
}
if (text[i] === '.') {
i++;
var fs = i;
while (i < N && text[i] >= '0' && text[i] <= '9') i++;
if (i === fs) fail(i, 'A decimal point with no digits after it.', 'JSON needs a digit on both sides: write 1.0, not 1.');
}
if (text[i] === 'e' || text[i] === 'E') {
i++;
if (text[i] === '+' || text[i] === '-') i++;
var es = i;
while (i < N && text[i] >= '0' && text[i] <= '9') i++;
if (i === es) fail(i, 'An exponent with no digits after it.', 'Write something like 1e6 or 2.5e-3.');
}
var raw = text.slice(start, i);
return { type: 'number', value: Number(raw), raw: raw, start: start, end: i, children: null };
}
function parseWord() {
var start = i;
while (i < N && /[A-Za-z_$0-9]/.test(text[i])) i++;
var w = text.slice(start, i);
if (w === 'true' || w === 'false') {
return { type: 'boolean', value: w === 'true', raw: w, start: start, end: i, children: null };
}
if (w === 'null') return { type: 'null', value: null, raw: w, start: start, end: i, children: null };
var low = w.toLowerCase();
if (low === 'true' || low === 'false' || low === 'null') {
fail(start, '`' + w + '` must be written in lower case.', 'JSON only knows `true`, `false` and `null` — exactly like that.');
}
if (low === 'undefined' || low === 'nan' || low === 'infinity') {
fail(start, 'JSON has no `' + w + '`.', 'Use `null` for a missing value; JSON numbers are always ordinary finite numbers.');
}
fail(start, 'Bare word `' + w + '` is not a JSON value.', 'Text has to be wrapped in double quotes: "' + w + '".');
}
function parseValue(depth) {
if (depth > 400) fail(i, 'This structure nests too deeply to read.', 'JSON Toolbox stops at 400 levels — something is probably unbalanced.');
ws();
if (i >= N) fail(i, 'The text ends where a value should be.', 'A JSON document holds exactly one value: an object, array, text, number, true, false or null.');
var ch = text[i];
if (ch === '{') return parseObject(depth);
if (ch === '[') return parseArray(depth);
if (ch === '"') return parseString('piece of text');
if (ch === '-' || (ch >= '0' && ch <= '9')) return parseNumber();
if (/[A-Za-z_$]/.test(ch)) return parseWord();
if (ch === '') fail(i, 'There is an invisible byte-order mark here.', 'Some editors bury a hidden BOM at the very start of a file — delete the first character and the rest parses fine.');
if (ch === "'") fail(i, 'JSON text uses double quotes, not single ones.', "Swap the ' … ' around this value for \" … \".");
if (ch === '.') fail(i, 'A number cannot start with a decimal point.', 'Write 0.5 rather than .5.');
if (ch === '+') fail(i, 'A number cannot start with a plus sign.', 'Just write the digits: 12 rather than +12.');
if (ch === '}' || ch === ']') fail(i, 'Found ' + show(i) + ' where a value should be.', 'Something was closed before anything was put inside it.');
if (ch === ',') fail(i, 'Found a comma where a value should be.', 'Two commas in a row, or a comma before the first item.');
fail(i, 'Found ' + show(i) + ' where a value should be.', 'Values are objects, arrays, quoted text, numbers, true, false or null.');
}
function parseObject(depth) {
var start = i, startLine = lineOf(i) + 1;
i++;
var node = { type: 'object', children: [], start: start, end: -1, raw: null, value: null };
var seen = Object.create(null), dupes = [];
ws();
if (text[i] === '}') { i++; node.end = i; node.dupes = dupes; return node; }
while (true) {
ws();
if (i >= N) fail(i, 'This object is never closed.', 'The `{` on line ' + startLine + ' still needs a matching `}`.');
if (text[i] === '}') fail(i, 'There is a comma just before this `}`.', 'JSON does not allow a trailing comma — delete the comma after the last property.');
if (text[i] !== '"') {
if (text[i] === "'") fail(i, 'Property names use double quotes, not single ones.', "Write \"name\" rather than 'name'.");
if (/[A-Za-z_$]/.test(text[i])) {
var wstart = i, j = i;
while (j < N && /[A-Za-z_$0-9]/.test(text[j])) j++;
fail(wstart, 'Property names must be quoted.', 'Write "' + text.slice(wstart, j) + '": … — unquoted names are JavaScript, not JSON.');
}
fail(i, 'Found ' + show(i) + ' where a property name should be.', 'Inside an object every entry looks like "name": value.');
}
var keyNode = parseString('property name');
if (seen[keyNode.value]) { if (dupes.indexOf(keyNode.value) < 0) dupes.push(keyNode.value); }
seen[keyNode.value] = true;
ws();
if (text[i] !== ':') {
if (i >= N) fail(i, 'The text ends right after a property name.', 'Every property needs a value: "' + keyNode.value + '": something.');
if (text[i] === ',' || text[i] === '}') fail(i, 'Property "' + keyNode.value + '" has no value.', 'Write "' + keyNode.value + '": value — a name on its own is not enough.');
fail(i, 'Expected a `:` after "' + keyNode.value + '", found ' + show(i) + '.', 'The name and its value are separated by a colon.');
}
i++;
var v = parseValue(depth + 1);
v.key = keyNode.value;
v.keyStart = keyNode.start;
node.children.push(v);
ws();
if (text[i] === ',') { i++; continue; }
if (text[i] === '}') { i++; node.end = i; node.dupes = dupes; return node; }
if (i >= N) fail(i, 'This object is never closed.', 'The `{` on line ' + startLine + ' still needs a matching `}`.');
if (text[i] === ']') fail(i, 'An object is being closed with `]`.', 'The `{` on line ' + startLine + ' has to end with `}`.');
fail(i, 'Expected `,` or `}` after this value, found ' + show(i) + '.', 'Properties are separated by commas, and the last one is followed by `}`.');
}
}
function parseArray(depth) {
var start = i, startLine = lineOf(i) + 1;
i++;
var node = { type: 'array', children: [], start: start, end: -1, raw: null, value: null };
ws();
if (text[i] === ']') { i++; node.end = i; return node; }
while (true) {
ws();
if (i >= N) fail(i, 'This list is never closed.', 'The `[` on line ' + startLine + ' still needs a matching `]`.');
if (text[i] === ']') fail(i, 'There is a comma just before this `]`.', 'JSON does not allow a trailing comma — delete the comma after the last item.');
var v = parseValue(depth + 1);
v.index = node.children.length;
node.children.push(v);
ws();
if (text[i] === ',') { i++; continue; }
if (text[i] === ']') { i++; node.end = i; return node; }
if (i >= N) fail(i, 'This list is never closed.', 'The `[` on line ' + startLine + ' still needs a matching `]`.');
if (text[i] === '}') fail(i, 'A list is being closed with `}`.', 'The `[` on line ' + startLine + ' has to end with `]`.');
fail(i, 'Expected `,` or `]` after this item, found ' + show(i) + '.', 'Items are separated by commas, and the last one is followed by `]`.');
}
}
if (!text.trim()) {
var e0 = new ParseError('Nothing to read yet.', 0, 'Paste some JSON, or pick one of the samples above.');
e0.line = 1; e0.col = 1; e0.lineText = ''; e0.empty = true;
throw e0;
}
var root = parseValue(0);
ws();
if (i < N) {
fail(i, 'Extra content after the end of the document.',
'A JSON file holds exactly one value. Everything from here on is left over — wrap the whole thing in `[ … ]` if you meant a list.');
}
return root;
}
/* ---------------- helpers ---------------- */
var IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
function esc(s) {
return String(s).replace(/[&<>"]/g, function (c) {
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"';
});
}
function seg(key) { return IDENT.test(key) ? '.' + key : '[' + JSON.stringify(key) + ']'; }
function childPath(path, child, idx) {
return child.key !== undefined ? path + seg(child.key) : path + '[' + idx + ']';
}
function bytesOf(s) {
if (window.TextEncoder) { try { return new TextEncoder().encode(s).length; } catch (e) {} }
return unescape(encodeURIComponent(s)).length;
}
function fmtBytes(n) {
if (n < 1024) return { v: String(n), u: 'B' };
if (n < 1024 * 1024) return { v: (n / 1024).toFixed(n < 10240 ? 1 : 0), u: 'KB' };
return { v: (n / 1048576).toFixed(1), u: 'MB' };
}
function group(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); }
function clip(s, max) { return s.length > max ? s.slice(0, max - 1) + '…' : s; }
/* serialise our own node tree — no JSON.parse anywhere in the pipeline */
function ser(node, ind, cur) {
var nl = ind ? '\n' : '', pad = cur + ind, close = ind ? cur : '';
if (node.type === 'object') {
if (!node.children.length) return '{}';
var parts = node.children.map(function (c) {
return pad + JSON.stringify(c.key) + ':' + (ind ? ' ' : '') + ser(c, ind, pad);
});
return '{' + nl + parts.join(',' + nl) + nl + close + '}';
}
if (node.type === 'array') {
if (!node.children.length) return '[]';
var items = node.children.map(function (c) { return pad + ser(c, ind, pad); });
return '[' + nl + items.join(',' + nl) + nl + close + ']';
}
if (node.type === 'string') return JSON.stringify(node.value);
return node.raw;
}
function indentStr() {
var v = el.indent.value;
return v === 'tab' ? '\t' : v === '4' ? ' ' : ' ';
}
/* ---------------- state ---------------- */
var S = {
root: null, err: null, dupes: [], collapsed: Object.create(null),
qCollapsed: Object.create(null), /* folds made while a search is active */
sel: '$', rows: [], q: '', stale: false, matches: 0
};
function scalarText(node) {
if (node.type === 'string') return node.value;
return node.raw;
}
function preview(node) {
var bits = [], i, c;
if (node.type === 'object') {
for (i = 0; i < node.children.length && i < 3; i++) {
c = node.children[i];
bits.push(c.key + ': ' + (c.type === 'object' ? '{…}' : c.type === 'array' ? '[…]' : clip(c.type === 'string' ? '"' + c.value + '"' : c.raw, 16)));
}
return '{ ' + bits.join(', ') + (node.children.length > 3 ? ', …' : '') + ' }';
}
for (i = 0; i < node.children.length && i < 4; i++) {
c = node.children[i];
bits.push(c.type === 'object' ? '{…}' : c.type === 'array' ? '[…]' : clip(c.type === 'string' ? '"' + c.value + '"' : c.raw, 14));
}
return '[ ' + bits.join(', ') + (node.children.length > 4 ? ', …' : '') + ' ]';
}
/* ---------------- stats ---------------- */
var TYPE_ORDER = ['object', 'array', 'string', 'number', 'boolean', 'null'];
var TYPE_VAR = { object: '--t-obj', array: '--t-arr', string: '--t-str', number: '--t-num', boolean: '--t-bool', 'null': '--t-null' };
var TYPE_LABEL = { object: 'objects', array: 'lists', string: 'text', number: 'numbers', boolean: 'true/false', 'null': 'null' };
function stats(root, text) {
var counts = { object: 0, array: 0, string: 0, number: 0, boolean: 0, 'null': 0 };
var nodes = 0, keys = 0, depth = 0, longest = 0, dupes = [];
(function walk(n, d) {
nodes++; counts[n.type]++;
if (d > depth) depth = d;
if (n.key !== undefined) keys++;
if (n.children) {
if (n.type === 'array' && n.children.length > longest) longest = n.children.length;
if (n.dupes && n.dupes.length) { n.dupes.forEach(function (k) { if (dupes.indexOf(k) < 0) dupes.push(k); }); }
for (var i = 0; i < n.children.length; i++) walk(n.children[i], d + 1);
}
})(root, 1);
var min = ser(root, '', ''), pretty = ser(root, indentStr(), '');
var b = bytesOf(text), mb = bytesOf(min);
return {
counts: counts, nodes: nodes, keys: keys, depth: depth, longest: longest,
dupes: dupes, min: min, pretty: pretty, bytes: b, minBytes: mb,
saved: b > 0 ? Math.max(0, Math.round((1 - mb / b) * 100)) : 0
};
}
function tile(k, v, u, cls) {
return '<div class="stat' + (cls ? ' ' + cls : '') + '"><span class="k">' + esc(k) + '</span>' +
'<span class="v">' + esc(v) + (u ? '<small>' + esc(u) + '</small>' : '') + '</span></div>';
}
function renderStats(st, text, blank) {
if (!st) {
var fb = fmtBytes(bytesOf(text));
el.stats.innerHTML = tile('size', fb.v, fb.u) + tile('lines', group(text.split('\n').length)) +
tile('nodes', '—') + tile('keys', '—') + tile('depth', '—') +
(blank ? tile('parse', 'waiting') : tile('parse', 'failed', '', 'is-bad'));
el.compbar.innerHTML = ''; el.legend.innerHTML = '';
document.querySelector('.comp').hidden = true;
return;
}
var a = fmtBytes(st.bytes), m = fmtBytes(st.minBytes);
el.stats.innerHTML =
tile('size', a.v, a.u) +
tile('minified', m.v, m.u) +
tile('saved', st.saved + '%', '') +
tile('nodes', group(st.nodes)) +
tile('keys', group(st.keys)) +
tile('max depth', String(st.depth));
var total = st.nodes || 1, barHtml = '', legHtml = '';
TYPE_ORDER.forEach(function (t) {
var n = st.counts[t];
if (!n) return;
var pct = (n / total) * 100;
barHtml += '<span style="width:' + pct.toFixed(2) + '%;background:var(' + TYPE_VAR[t] + ')" title="' +
esc(n + ' ' + TYPE_LABEL[t]) + '"></span>';
legHtml += '<b><i style="background:var(' + TYPE_VAR[t] + ')"></i>' + esc(TYPE_LABEL[t]) +
' <em>' + group(n) + '</em></b>';
});
el.compbar.innerHTML = barHtml;
el.legend.innerHTML = legHtml;
document.querySelector('.comp').hidden = false;
}
/* ---------------- gutter + editor chrome ---------------- */
function renderGutter(text, badLine) {
var total = text.split('\n').length;
var lines = Math.min(total, 20000); /* numbering stops here; the count stays honest */
var out = '';
for (var i = 1; i <= lines; i++) out += '<b' + (i === badLine ? ' class="bad"' : '') + '>' + i + '</b>';
el.gi.innerHTML = out;
el.gi.style.transform = 'translateY(' + (-el.src.scrollTop) + 'px)';
el.lineCount.textContent = group(total) + (total === 1 ? ' line' : ' lines');
}
el.src.addEventListener('scroll', function () {
el.gi.style.transform = 'translateY(' + (-el.src.scrollTop) + 'px)';
});
el.src.addEventListener('focus', function () { el.ed.classList.add('focused'); });
el.src.addEventListener('blur', function () { el.ed.classList.remove('focused'); });
/* ---------------- error panel ---------------- */
function renderError(e, text) {
var lineText = e.lineText || '', col = e.col || 1;
/* expand tabs so the caret lands under the right character */
var expanded = '', vcol = col;
for (var i = 0; i < lineText.length; i++) {
if (lineText[i] === '\t') { var add = 2 - (expanded.length % 2); expanded += ' '.slice(0, add); if (i < col - 1) vcol += add - 1; }
else expanded += lineText[i];
}
var from = 0;
if (vcol > 60) { from = vcol - 45; expanded = '…' + expanded.slice(from); vcol = vcol - from + 1; }
expanded = clip(expanded, 90);
var num = String(e.line || 1), gut = num + ' │ ';
var caret = new Array(gut.length + vcol).join(' ') + '^';
el.err.innerHTML =
'<div class="hd">' +
(e.empty ? '' : '<span class="where">line ' + esc(num) + ', col ' + esc(String(col)) + '</span>') +
'<span class="msg">' + esc(e.message) + '</span></div>' +
(e.hint ? '<div class="hint">' + esc(e.hint) + '</div>' : '') +
(e.empty ? '' :
'<pre><span class="ln">' + esc(gut) + '</span>' + esc(expanded) + '\n' +
'<span class="caret">' + esc(caret) + '</span></pre>' +
'<div class="acts"><button class="btn" id="btnJump" type="button">jump to it</button></div>');
el.err.hidden = false;
var jump = document.getElementById('btnJump');
if (jump) jump.addEventListener('click', function () {
el.src.focus();
var at = Math.min(e.index, el.src.value.length);
try { el.src.setSelectionRange(at, Math.min(at + 1, el.src.value.length)); } catch (err) {}
el.src.scrollTop = Math.max(0, ((e.line || 1) - 1) * 20 - el.src.clientHeight / 2);
el.gi.style.transform = 'translateY(' + (-el.src.scrollTop) + 'px)';
});
}
/* ---------------- tree ---------------- */
var CHEV = '<svg viewBox="0 0 8 10" aria-hidden="true"><path d="M1 1l5 4-5 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
var MAX_ROWS = 2500;
function matchSets(root, q) {
var keep = Object.create(null), hit = Object.create(null), n = 0;
(function walk(node, path, parents, inside) {
var self = [];
if (node.key !== undefined && node.key.toLowerCase().indexOf(q) >= 0) self.push('key');
if (!node.children && scalarText(node).toLowerCase().indexOf(q) >= 0) self.push('value');
var mine = self.length > 0;
if (mine) {
n++; hit[path] = self;
for (var p = 0; p < parents.length; p++) keep[parents[p]] = true;
}
/* a match keeps its whole subtree, so an opened branch is never empty */
if (mine || inside) keep[path] = true;
if (node.children) {
var next = parents.concat([path]);
for (var i = 0; i < node.children.length; i++) {
walk(node.children[i], childPath(path, node.children[i], i), next, mine || inside);
}
}
})(root, '$', [], false);
return { keep: keep, hit: hit, n: n };
}
function buildRows() {
var rows = [], q = S.q, ms = null;
S.matches = 0;
if (!S.root) { S.rows = rows; return { rows: rows, matches: 0 }; }
if (q) ms = matchSets(S.root, q);
(function push(node, path, depth) {
if (ms && !ms.keep[path]) return;
var kids = node.children && node.children.length;
var open = ms ? !S.qCollapsed[path] : !S.collapsed[path];
rows.push({
node: node, path: path, depth: depth, kids: !!kids, open: !!(kids && open),
hit: ms ? (ms.hit[path] || null) : null
});
if (kids && open) {
for (var i = 0; i < node.children.length; i++) {
push(node.children[i], childPath(path, node.children[i], i), depth + 1);
}
}
})(S.root, '$', 0);
S.rows = rows;
S.matches = ms ? ms.n : 0;
return { rows: rows, matches: S.matches };
}
function markup(text, q, hitting) {
var safe = esc(text);
if (!q || !hitting) return safe;
var lower = text.toLowerCase(), out = '', at = 0, k;
while ((k = lower.indexOf(q, at)) >= 0) {
out += esc(text.slice(at, k)) + '<mark>' + esc(text.slice(k, k + q.length)) + '</mark>';
at = k + q.length;
if (q.length === 0) break;
}
out += esc(text.slice(at));
return out || safe;
}
function renderTree() {
var built = buildRows(), rows = built.rows;
el.tree.innerHTML = '';
if (!rows.length) {
el.tree.innerHTML = '<p class="empty">' + (
S.q ? 'No key or value contains <b class="mono">' + esc(S.q) + '</b>.'
: (S.err && !S.err.empty) ? 'Fix the problem on the left and the tree shows up here.'
: 'Paste some JSON on the left, or pick a sample above.') + '</p>';
updateSearchState(built.matches);
return;
}
var frag = document.createDocumentFragment();
var shown = Math.min(rows.length, MAX_ROWS);
for (var i = 0; i < shown; i++) {
var r = rows[i], node = r.node, html = '';
var div = document.createElement('div');
div.className = 'row' + (r.open ? ' open' : '');
div.setAttribute('role', 'treeitem');
div.setAttribute('aria-level', String(r.depth + 1));
div.setAttribute('tabindex', '-1');
div.dataset.i = String(i);
div.style.paddingLeft = (8 + r.depth * 14) + 'px';
if (r.kids) div.setAttribute('aria-expanded', r.open ? 'true' : 'false');
if (r.path === S.sel) div.setAttribute('aria-selected', 'true');
html += r.kids
? '<button class="tw" type="button" tabindex="-1" aria-hidden="true">' + CHEV + '</button>'
: '<span class="tw blank" aria-hidden="true"></span>';
var hitKey = r.hit && r.hit.indexOf('key') >= 0;
var hitVal = r.hit && r.hit.indexOf('value') >= 0;
if (node.key !== undefined) {
html += '<span class="k">' + markup(node.key, S.q, hitKey) + '</span><span class="colon">:</span>';
} else if (node.index !== undefined) {
html += '<span class="idx">' + node.index + '</span><span class="colon">:</span>';
}
if (node.type === 'object' || node.type === 'array') {
var isObj = node.type === 'object';
var n = node.children.length;
var label = n === 0 ? (isObj ? '{ }' : '[ ]') : (isObj ? '{' : '[');
html += '<span class="v ' + (isObj ? 'o' : 'a') + '">' + label + '</span>';
if (n) {
html += '<span class="count ' + (isObj ? 'o' : 'a') + '">' + n + (isObj ? (n === 1 ? ' key' : ' keys') : (n === 1 ? ' item' : ' items')) + '</span>';
if (!r.open) html += '<span class="prev">' + esc(clip(preview(node), 58)) + '</span>';
}
} else if (node.type === 'string') {
var t = clip(node.value, 96);
html += '<span class="v s">"' + markup(t, S.q, hitVal) + '"</span>';
} else if (node.type === 'number') {
html += '<span class="v n">' + markup(node.raw, S.q, hitVal) + '</span>';
} else if (node.type === 'boolean') {
html += '<span class="v b">' + markup(node.raw, S.q, hitVal) + '</span>';
} else {
html += '<span class="v z">' + markup(node.raw, S.q, hitVal) + '</span>';
}
html += '<span class="copyhint">click = copy path</span>';
div.innerHTML = html;
frag.appendChild(div);
}
if (rows.length > shown) {
var more = document.createElement('p');
more.className = 'more';
more.textContent = '… ' + group(rows.length - shown) + ' more rows hidden — collapse a branch or search to narrow it down.';
frag.appendChild(more);
}
el.tree.appendChild(frag);
var selEl = el.tree.querySelector('[aria-selected="true"]');
(selEl || el.tree.querySelector('.row')).setAttribute('tabindex', '0');
updateSearchState(built.matches);
}
function updateSearchState(matches) {
/* keyed off the box, not the query: whitespace-only text still needs a way out */
el.qClear.hidden = !el.q.value;
if (!S.q) return;
el.live.textContent = matches + (matches === 1 ? ' match' : ' matches');
}
/* ---------------- selection, copying ---------------- */
var flashTimer = 0;
function flash(msg, kind) {
el.flash.textContent = msg;
el.flash.className = 'flash ' + (kind === 'bad' ? 'bad' : 'ok');
el.flash.hidden = false;
el.live.textContent = msg;
if (flashTimer) clearTimeout(flashTimer);
flashTimer = setTimeout(function () { el.flash.hidden = true; }, 1800);
}
function legacyCopy(text) {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
return ok;
}
function copy(text, label) {
var done = function () { flash('copied ' + label, 'ok'); };
var fallback = function () {
if (legacyCopy(text)) done();
else { el.path.focus(); el.path.select(); flash('copy blocked — select and copy', 'bad'); }
};
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
navigator.clipboard.writeText(text).then(done, fallback);
return;
} catch (e) { /* fall through */ }
}
fallback();
}
function rowByPath(path) {
for (var i = 0; i < S.rows.length; i++) if (S.rows[i].path === path) return i;
return -1;
}
function domRow(i) { return el.tree.querySelector('.row[data-i="' + i + '"]'); }
function select(i, focus) {
var r = S.rows[i];
if (!r) return;
S.sel = r.path;
var prev = el.tree.querySelector('[aria-selected="true"]');
if (prev) prev.removeAttribute('aria-selected');
/* exactly one row is ever in the tab order */
var prevTab = el.tree.querySelector('.row[tabindex="0"]');
if (prevTab) prevTab.setAttribute('tabindex', '-1');
var node = domRow(i);
if (node) {
node.setAttribute('aria-selected', 'true');
node.setAttribute('tabindex', '0');
if (focus) node.focus();
} else {
/* the selected row sits past the render cap — keep exactly one row tabbable
anyway, or the whole tree drops out of the tab order */
var first = el.tree.querySelector('.row');
if (first) first.setAttribute('tabindex', '0');
}
el.path.value = r.path;
}
function valueText(i) {
var r = S.rows[i];
if (!r) return '';
return ser(r.node, indentStr(), '');
}
function toggle(i, force) {
var r = S.rows[i];
if (!r || !r.kids) return;
var map = S.q ? S.qCollapsed : S.collapsed;
var open = force === undefined ? !r.open : force;
if (open) delete map[r.path]; else map[r.path] = true;
var keep = S.sel;
/* read focus BEFORE the re-render blows the focused row away */
var hadFocus = !!(document.activeElement && document.activeElement.classList &&
document.activeElement.classList.contains('row'));
renderTree();
var at = rowByPath(keep);
/* collapsing a branch can swallow the selected row — land on the branch itself */
if (at < 0) at = rowByPath(r.path);
if (at >= 0) select(at, hadFocus);
}
/* ---------------- tree events ---------------- */
el.tree.addEventListener('click', function (e) {
var row = e.target.closest ? e.target.closest('.row') : null;
if (!row) return;
var i = Number(row.dataset.i);
var tw = e.target.closest ? e.target.closest('.tw') : null;
if (tw) { select(i, false); toggle(i); return; }
select(i, false);
copy(S.rows[i].path, S.rows[i].path);
});
el.tree.addEventListener('keydown', function (e) {
var row = e.target.closest ? e.target.closest('.row') : null;
if (!row) return;
var i = Number(row.dataset.i), r = S.rows[i], last = Math.min(S.rows.length, MAX_ROWS) - 1;
if (!r) return;
if (e.ctrlKey || e.metaKey || e.altKey) return; /* leave Ctrl/Cmd shortcuts alone */
var k = e.key;
if (k === 'ArrowDown') { e.preventDefault(); if (i < last) select(i + 1, true); }
else if (k === 'ArrowUp') { e.preventDefault(); if (i > 0) select(i - 1, true); }
else if (k === 'ArrowRight') {
e.preventDefault();
if (r.kids && !r.open) toggle(i, true);
else if (i < last) select(i + 1, true);
} else if (k === 'ArrowLeft') {
e.preventDefault();
if (r.kids && r.open) toggle(i, false);
else {
for (var j = i - 1; j >= 0; j--) { if (S.rows[j].depth < r.depth) { select(j, true); break; } }
}
} else if (k === 'Home') { e.preventDefault(); select(0, true); }
else if (k === 'End') { e.preventDefault(); select(last, true); }
else if (k === 'Enter' || k === ' ') { e.preventDefault(); copy(r.path, r.path); }
else if (k === 'v' || k === 'V') { e.preventDefault(); copy(valueText(i), 'value of ' + r.path); }
});
/* ---------------- folding helpers ---------------- */
function clearSearch() {
el.q.value = ''; S.q = ''; S.qCollapsed = Object.create(null);
}
function resetFolds() {
S.collapsed = Object.create(null);
if (!S.root) return;
(function walk(n, path, depth) {
if (!n.children || !n.children.length) return;
if (depth >= 2) S.collapsed[path] = true;
for (var i = 0; i < n.children.length; i++) walk(n.children[i], childPath(path, n.children[i], i), depth + 1);
})(S.root, '$', 0);
}
/* folds the map the tree is actually reading right now, so a live search survives */
function setAllFolds(collapse) {
var map = Object.create(null);
if (collapse && S.root) {
(function walk(n, path, depth) {
if (!n.children || !n.children.length) return;
if (depth >= 1) map[path] = true;
for (var i = 0; i < n.children.length; i++) walk(n.children[i], childPath(path, n.children[i], i), depth + 1);
})(S.root, '$', 0);
}
if (S.q) S.qCollapsed = map; else S.collapsed = map;
}
/* ---------------- the main pass ---------------- */
var lastPretty = '', lastMin = '';
function run() {
var text = el.src.value, badLine = 0;
var root = null, e = null;
try {
root = parse(text);
} catch (ex) {
e = (ex instanceof ParseError) ? ex : (function () {
var g = new ParseError('This text could not be read as JSON.', 0, 'Try one of the samples above to see a working document.');
g.line = 1; g.col = 1; g.lineText = ''; g.empty = true;
return g;
})();
}
if (root) {
S.root = root; S.err = null; S.stale = false;
var st = stats(root, text);
lastPretty = st.pretty; lastMin = st.min;
renderStats(st, text);
el.err.hidden = true;
el.verdict.className = 'pill ok';
el.verdict.textContent = 'parsed ok · ' + group(st.nodes) + ' nodes';
el.tree.classList.remove('stale');
if (st.dupes.length) {
el.dupes.hidden = false;
el.dupes.innerHTML = 'Duplicate key' + (st.dupes.length > 1 ? 's' : '') + ' — only the last copy survives in most readers: ' +
st.dupes.slice(0, 4).map(function (k) { return '<code>' + esc(k) + '</code>'; }).join(', ') +
(st.dupes.length > 4 ? ' <code>+' + (st.dupes.length - 4) + ' more</code>' : '');
} else { el.dupes.hidden = true; }
var trimmed = text.replace(/^\s+|\s+$/g, '');
var label = trimmed === st.pretty ? 'pretty · ' + (el.indent.value === 'tab' ? 'tab' : el.indent.value + ' spaces')
: trimmed === st.min ? 'minified' : 'custom spacing';
el.fmtState.textContent = label;
el.fmtState.className = 'pill spacer';
el.fmtState.hidden = false;
document.getElementById('btnPretty').setAttribute('aria-pressed', trimmed === st.pretty ? 'true' : 'false');
document.getElementById('btnMinify').setAttribute('aria-pressed', trimmed === st.min ? 'true' : 'false');
} else {
S.err = e;
badLine = e.empty ? 0 : e.line;
renderError(e, text);
renderStats(null, text, !!e.empty);
el.dupes.hidden = true;
el.verdict.className = 'pill bad';
el.verdict.textContent = e.empty ? 'empty' : 'error on line ' + e.line;
el.fmtState.textContent = '';
el.fmtState.className = 'pill spacer';
el.fmtState.hidden = true;
document.getElementById('btnPretty').setAttribute('aria-pressed', 'false');
document.getElementById('btnMinify').setAttribute('aria-pressed', 'false');
if (S.root) { S.stale = true; el.tree.classList.add('stale'); }
else { S.stale = false; el.tree.classList.remove('stale'); }
}
renderGutter(text, badLine);
refreshTree();
}
function countMatches() { return S.matches || 0; }
function load(text) {
text = String(text).replace(/^\uFEFF/, ''); /* editors love to leave a BOM behind */
el.src.value = text;
S.root = null; S.sel = '$';
el.q.value = ''; S.q = ''; S.qCollapsed = Object.create(null);
el.src.scrollTop = 0;
var pre = null;
try { pre = parse(text); } catch (e) { pre = null; }
S.root = pre;
resetFolds();
if (!pre) S.root = null;
run();
}
function refreshTree() {
renderTree();
var at = rowByPath(S.sel);
if (at >= 0) select(at, false);
else if (S.rows.length) select(0, false);
else el.path.value = S.sel;
var qs = document.getElementById('qState');
if (S.q) {
qs.hidden = false;
var n = countMatches();
qs.textContent = n ? n + (n === 1 ? ' hit · branches opened' : ' hits · branches opened') : 'no hits';
qs.className = 'pill' + (n ? '' : ' bad');
} else if (S.stale) {
qs.hidden = false;
qs.textContent = 'last good parse';
qs.className = 'pill warn';
} else { qs.hidden = true; }
}
/* ---------------- controls ---------------- */
function reformat(kind) {
/* S.root can be a *stale* tree from the last good parse — reformatting from it
would silently overwrite whatever the editor holds now. Refuse instead. */
if (!S.root || S.err) { flash(S.err && S.err.empty ? 'nothing to format yet' : 'fix the error first', 'bad'); return; }
var before = el.src.value;
var out = kind === 'min' ? ser(S.root, '', '') : ser(S.root, indentStr(), '');
if (out === before.replace(/^\s+|\s+$/g, '')) { flash('already ' + (kind === 'min' ? 'minified' : 'pretty'), 'ok'); return; }
el.src.value = out;
el.src.scrollTop = 0;
run();
flash(kind === 'min' ? 'minified' : 'prettified', 'ok');
}
document.getElementById('btnPretty').addEventListener('click', function () { reformat('pretty'); });
document.getElementById('btnMinify').addEventListener('click', function () { reformat('min'); });
el.indent.addEventListener('change', function () {
if (S.root && el.src.value.replace(/^\s+|\s+$/g, '') === lastPretty) reformat('pretty');
else run();
});
document.getElementById('s-bakery').addEventListener('click', function () { load(SAMPLES.bakery); flash('loaded bakery sample', 'ok'); });
document.getElementById('s-playlist').addEventListener('click', function () { load(SAMPLES.playlist); flash('loaded playlist sample', 'ok'); });
document.getElementById('s-garden').addEventListener('click', function () { load(SAMPLES.garden); flash('loaded greenhouse sample', 'ok'); });
document.getElementById('s-broken').addEventListener('click', function () {
load(SAMPLES.broken);
el.live.textContent = 'loaded a document with a deliberate mistake';
});
document.getElementById('btnCopyAll').addEventListener('click', function () {
copy(el.src.value, 'the whole document');
});
document.getElementById('btnExpand').addEventListener('click', function () {
setAllFolds(false); refreshTree();
});
document.getElementById('btnCollapse').addEventListener('click', function () {
setAllFolds(true); refreshTree();
});
document.getElementById('btnCopyPath').addEventListener('click', function () {
copy(el.path.value, el.path.value);
});
document.getElementById('btnCopyVal').addEventListener('click', function () {
var at = rowByPath(S.sel);
if (at < 0) { flash('pick a row first', 'bad'); return; }
copy(valueText(at), 'value of ' + S.sel);
});
/* search */
var qTimer = 0;
el.q.addEventListener('input', function () {
if (qTimer) clearTimeout(qTimer);
qTimer = setTimeout(function () {
var next = el.q.value.trim().toLowerCase();
if (next !== S.q) { S.q = next; S.qCollapsed = Object.create(null); }
refreshTree();
}, 90);
});
el.q.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && el.q.value) { e.preventDefault(); clearSearch(); refreshTree(); }
});
el.qClear.addEventListener('click', function () {
clearSearch(); refreshTree(); el.q.focus();
});
/* typing */
var tTimer = 0;
el.src.addEventListener('input', function () {
if (tTimer) clearTimeout(tTimer);
tTimer = setTimeout(run, 130);
});
/* drop a file (read locally — nothing is uploaded anywhere) */
function isFileDrag(e) {
var dt = e.dataTransfer;
if (!dt) return false;
if (dt.files && dt.files.length) return true;
var types = dt.types;
if (!types) return false;
for (var i = 0; i < types.length; i++) if (types[i] === 'Files') return true;
return false;
}
['dragenter', 'dragover'].forEach(function (t) {
el.ed.addEventListener(t, function (e) {
if (!isFileDrag(e)) return; /* a plain text drag belongs to the textarea */
e.preventDefault(); el.ed.classList.add('drop');
});
});
['dragleave', 'dragend'].forEach(function (t) {
el.ed.addEventListener(t, function () { el.ed.classList.remove('drop'); });
});
function loadFile(f) {
if (!f) return;
if (f.size > 4 * 1024 * 1024) { flash('that file is over 4 MB', 'bad'); return; }
var rd = new FileReader();
rd.onload = function () { load(String(rd.result)); flash('loaded ' + f.name, 'ok'); };
rd.onerror = function () { flash('could not read that file', 'bad'); };
rd.readAsText(f);
}
/* one handler for the whole document: a *file* drop that misses the editor would
otherwise make the browser navigate to it and throw the demo away. Text drags are
left alone so you can still drag a snippet straight into the editor. */
document.addEventListener('dragover', function (e) {
if (isFileDrag(e)) e.preventDefault();
});
document.addEventListener('drop', function (e) {
el.ed.classList.remove('drop');
if (!isFileDrag(e)) return;
e.preventDefault();
loadFile(e.dataTransfer.files && e.dataTransfer.files[0]);
});
/* "/" jumps to search when you are not typing in a field */
document.addEventListener('keydown', function (e) {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target, tag = t && t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
e.preventDefault();
el.q.focus();
el.q.select();
});
/* ---------------- go ---------------- */
load(SAMPLES.bakery);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.