Why I wrote this: The JSON Formatter was the first tool on aijsons.com — built in an afternoon, refined over 18 months. Developers ask whether we use Monaco, CodeMirror, or a React wrapper around jsonlint. We use none of them. The core is ~400 lines of vanilla JavaScript in js/app.js, served without a bundler. This article walks through the implementation decisions so you can build your own or audit ours.
Design Constraints We Chose Up Front
- Zero build step — GitHub Pages serves static files; no Webpack on deploy path
- Client-side only — see our privacy architecture article
- No framework — 39 tools share one
app.js; React would multiply bundle size per page - Progressive enhancement — textarea works without JS; buttons enhance with format/validate
These constraints trade DX for auditability and load time. Our formatter page ships ~12KB of gzipped JS total including shared utilities.
Core Format Function (10 Lines That Matter)
Pretty-printing is two native calls:
function formatJSON(input) {
const parsed = JSON.parse(input); // throws on invalid JSON
return JSON.stringify(parsed, null, 2); // 2-space indent
}
Production code wraps this in try/catch with line-number extraction — from our repo:
const formatJSON = () => {
try {
const formatted = JSON.stringify(JSON.parse(jsonInput.value), null, 2);
jsonOutput.value = formatted;
jsonHighlight.innerHTML = syntaxHighlight(formatted);
setStatus(statusBadge, 'Valid JSON', true);
} catch (e) {
const m = e.message.match(/position\s+(\d+)/);
const line = m ? getLine(jsonInput.value, parseInt(m[1], 10)) : null;
showError('SyntaxError', e.message, line);
}
};
JSON.parse throws SyntaxError with position N in the message — we map byte position to line number for user-friendly errors:
const getLine = (str, pos) => {
let line = 1;
for (let i = 0; i < pos && i < str.length; i++) {
if (str[i] === '\n') line++;
}
return line;
};
Syntax Highlighting Without a Library
We highlight by regex-tokenizing the formatted string into HTML spans. The pattern matches keys, strings, numbers, booleans, and null:
const syntaxHighlight = (json) => {
if (typeof json !== 'string') json = JSON.stringify(json, null, 2);
json = json.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
return json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
(match) => {
let cls = 'json-number';
if (/^"/.test(match)) {
cls = /:$/.test(match) ? 'json-key' : 'json-string';
if (/:$/.test(match)) match = match.slice(0, -1) + '</span>:' ;
} else if (/true|false/.test(match)) cls = 'json-boolean';
else if (/null/.test(match) cls = 'json-null';
return '<span class="' + cls + '">' + match + '</span>';
}
);
};
Trade-off: regex highlighting mis-colors edge cases (strings containing true as substring). Acceptable for a formatter; unacceptable for a code editor. We disable highlighting above 5MB to avoid generating megabytes of HTML — see 50MB guide.
Minify and Validate: Same Parse, Different Output
// Minify — no whitespace
const compressed = JSON.stringify(JSON.parse(input));
// Validate — parse only, no output transform
JSON.parse(input);
Three buttons, one parser. DRY at the logic layer even if the UI has separate handlers.
JSON Repair: When Users Paste "Almost JSON"
API logs and JavaScript configs include trailing commas, single quotes, and comments. Strict JSON.parse rejects them. Our repair pipeline applies rules sequentially and reports what changed:
let fixed = input;
fixed = fixed.replace(/\/\/[^\n\r]*/g, ''); // strip // comments
fixed = fixed.replace(/\/\*[\s\S]*?\*\//g, ''); // strip /* */
fixed = fixed.replace(/,\s*([}\]])/g, '$1'); // trailing commas
fixed = fixed.replace(/'/g, '"'); // single → double quotes
fixed = fixed.replace(/\bundefined\b/g, 'null');
const parsed = JSON.parse(fixed);
Repair is best-effort — not a full JSON5 parser. We show a warning listing applied fixes so users know the output is not byte-identical to input. For strict pipelines, use Validate without Repair.
Key Sorting: Recursive Object Walk
const sortObjectKeys = (obj) => {
if (Array.isArray(obj)) return obj.map(sortObjectKeys);
if (obj !== null && typeof obj === 'object') {
return Object.keys(obj).sort().reduce((acc, key) => {
acc[key] = sortObjectKeys(obj[key]);
return acc;
}, {});
}
return obj;
};
Sorted keys make diffs stable — essential when pairing with our JSON Compare tool.
UI Wiring: Event Delegation Pattern
Each tool page includes shared markup IDs (jsonInput, btnFormat). app.js binds on DOMContentLoaded:
document.getElementById('btnFormat')?.addEventListener('click', formatJSON);
document.getElementById('btnValidate')?.addEventListener('click', validateJSON);
document.getElementById('btnCompress')?.addEventListener('click', compressJSON);
Optional chaining (?.) lets one script load on pages that only implement a subset of tools without throwing.
Why Not React or Monaco?
| Approach | Pros | Why we skipped |
|---|---|---|
| Monaco Editor | Full IDE features | ~2MB gzip; overkill for format/validate |
| React + Vite | Component reuse | Build step conflicts with GitHub Pages simplicity |
| Vanilla JS | Tiny, auditable, fast FCP | Manual DOM; no virtual DOM safety net |
Testing Without a Test Suite
We have no Jest config. Formatter correctness is guarded by:
- Manual fixture files in tool demo chips (invalid trailing comma, Unicode BOM, nested arrays)
- GitHub Actions CI validating tool pages exist and sitemap links resolve — see CI guide
- User reports via GitHub Issues — edge cases become new demo chips
For a single-function tool, exhaustive unit tests add maintenance cost. For a library, they would be mandatory. Know which you are building.
Minimal HTML Shell
<textarea id="jsonInput" class="input-area" spellcheck="false"></textarea>
<button id="btnFormat" class="btn btn-primary">Format</button>
<button id="btnValidate">Validate</button>
<textarea id="jsonOutput" readonly></textarea>
<pre id="jsonHighlight" aria-hidden="true"></pre>
<script src="/js/app.js" defer></script>
The highlight <pre> overlays the output textarea with matching font metrics — CSS in styles.css aligns them pixel-perfect for the illusion of inline highlighting.
What I Would Add Next
- Web Worker parse — already planned for large files; main thread format stays for <1MB
- JSON5 mode toggle — explicit opt-in instead of silent repair
- Column in error panel — V8 now exposes column in some errors; map that too
Key Takeaways
- Format =
JSON.parse+JSON.stringify(x, null, 2)— everything else is UX - Line numbers from
positionin SyntaxError messages — essential for user trust - Regex syntax highlighting is good enough for formatters, not for editors
- Repair rules handle real-world messy input; document what you changed
- Vanilla JS keeps the tool auditable, tiny, and compatible with static hosting
Source Code
Full implementation: github.com/cdsyab1995-hash/json-website/blob/main/js/app.js (MIT License)