JSON Repair — JavaScript source
Fix broken JSON - trailing commas, single quotes, unquoted keys, comments, Python constants, BOM and truncated documents - and get clean pretty-printed JSON plus a list of every repair applied. Runs entirely in your browser.
This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
/**
* json-repair — salvage broken JSON back to valid, pretty-printed JSON.
*
* Language: JavaScript (ES2020+, runs unmodified in Node 16+ and modern browsers)
* Source: CosmoDev polyglot showcase port of the JSON Repair tool, ported
* from src/lib/jsonRepair.ts (the canonical TypeScript
* implementation) and cli/json-repair/json-repair.go (the live Go
* CLI twin).
* License: display source — part of CosmoDev's polyglot tool pages.
*
* Design goals:
* - Pure + deterministic; never throws (failures land in result.error).
* - Functionally equivalent to the TS / Go references: same inputs -> same
* outputs.
* - Self-contained: stdlib only (no npm dependencies). The final parse and
* pretty-print use JSON.parse + JSON.stringify(_, null, 2), exactly like
* the TS reference.
*
* Pipeline (each pass is a small pure function, applied only while it changes
* the text): strip invisible characters -> single to double quotes -> strip
* JS comments -> quote unquoted keys -> Python True/False/None -> remove
* trailing commas -> close truncated brackets.
*/
/**
* [start, end] index pairs of the double-quoted strings in text (quotes
* included). An unterminated string extends to the last character.
*/
export function stringSpans(text) {
const spans = [];
let open = null;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (open === null) {
if (ch === '"') open = i;
} else if (ch === '\\') {
i++; // skip the escaped character
} else if (ch === '"') {
spans.push([open, i]);
open = null;
}
}
if (open !== null) spans.push([open, text.length - 1]);
return spans;
}
/** True when index i sits inside one of the (ordered, non-overlapping) spans. */
function insideString(spans, i) {
for (const [start, end] of spans) {
if (i >= start) {
if (i <= end) return true;
} else {
return false; // spans are ascending: i is before this one and all after it
}
}
return false;
}
// BOM, ZWSP, ZWNJ, ZWJ, word joiner — invisible characters that break JSON.parse.
const INVISIBLE_CHARS = [0xfeff, 0x200b, 0x200c, 0x200d, 0x2060].map((c) =>
String.fromCharCode(c),
);
/** Remove copy-paste invisible characters: BOM and zero-width joiners/spaces. */
export function stripInvisible(text) {
let out = text;
for (const ch of INVISIBLE_CHARS) out = out.split(ch).join('');
return out;
}
/** Strip // line comments and block comments, string-aware. */
export function stripComments(text) {
const spans = stringSpans(text);
const cuts = []; // [start, endExclusive)
for (let i = 0; i < text.length; i++) {
if (insideString(spans, i)) continue;
if (text[i] === '/' && text[i + 1] === '/') {
let end = text.indexOf('\n', i);
if (end === -1) end = text.length;
cuts.push([i, end]); // keep the newline itself
i = end - 1;
} else if (text[i] === '/' && text[i + 1] === '*') {
const close = text.indexOf('*/', i + 2);
const end = close === -1 ? text.length : close + 2;
cuts.push([i, end]);
i = end - 1;
}
}
let out = text;
for (let k = cuts.length - 1; k >= 0; k--) {
const [a, b] = cuts[k];
out = out.slice(0, a) + out.slice(b);
}
return out;
}
/**
* Convert single-quoted strings/keys to double-quoted JSON strings:
* escape inner double quotes, collapse \' to ', keep every other escape.
*/
export function singleToDoubleQuotes(text) {
let out = '';
let i = 0;
while (i < text.length) {
const ch = text[i];
if (ch === '"') {
// Copy a double-quoted string verbatim (apostrophes inside stay put).
let j = i + 1;
while (j < text.length) {
if (text[j] === '\\') j += 2;
else if (text[j] === '"') {
j++;
break;
} else {
j++;
}
}
out += text.slice(i, j);
i = j;
} else if (ch === "'") {
let body = '';
let j = i + 1;
while (j < text.length) {
const c = text[j];
if (c === '\\' && j + 1 < text.length) {
const next = text[j + 1];
body += next === "'" ? "'" : c + next;
j += 2;
} else if (c === "'") {
j++;
break;
} else if (c === '"') {
body += '\\"';
j++;
} else {
body += c;
j++;
}
}
out += `"${body}"`;
i = j;
} else {
out += ch;
i++;
}
}
return out;
}
const KEY_START = /[A-Za-z_$]/;
const KEY_CHAR = /[A-Za-z0-9_$-]/;
/** Wrap bare identifier keys ({name: 1} -> {"name": 1}), string-aware. */
export function quoteUnquotedKeys(text) {
const spans = stringSpans(text);
const edits = [];
for (let i = 0; i < text.length; i++) {
if (insideString(spans, i)) continue;
const ch = text[i];
if (ch !== '{' && ch !== ',') continue;
let j = i + 1;
while (j < text.length && /\s/.test(text[j])) j++;
if (!KEY_START.test(text[j] ?? '')) continue;
let k = j;
while (k < text.length && KEY_CHAR.test(text[k])) k++;
let l = k;
while (l < text.length && /\s/.test(text[l])) l++;
if (text[l] === ':') edits.push([j, k]);
}
let out = text;
for (let e = edits.length - 1; e >= 0; e--) {
const [a, b] = edits[e];
out = out.slice(0, a) + `"${out.slice(a, b)}"` + out.slice(b);
}
return out;
}
const PY_CONSTANTS = {
True: 'true',
False: 'false',
None: 'null',
};
/** Rewrite bare Python constants (True/False/None) to JSON (true/false/null), string-aware. */
export function fixPythonConstants(text) {
const spans = stringSpans(text);
const edits = [];
const re = /\b(True|False|None)\b/g;
let m;
while ((m = re.exec(text)) !== null) {
if (!insideString(spans, m.index)) {
edits.push([m.index, m.index + m[0].length, PY_CONSTANTS[m[0]]]);
}
}
let out = text;
for (let k = edits.length - 1; k >= 0; k--) {
const [a, b, rep] = edits[k];
out = out.slice(0, a) + rep + out.slice(b);
}
return out;
}
/** Remove commas followed only by whitespace and a closing } or ], string-aware. */
export function stripTrailingCommas(text) {
const spans = stringSpans(text);
const cuts = [];
for (let i = 0; i < text.length; i++) {
if (text[i] !== ',' || insideString(spans, i)) continue;
let j = i + 1;
while (j < text.length && /\s/.test(text[j])) j++;
if (text[j] === '}' || text[j] === ']') cuts.push(i);
}
let out = text;
for (let k = cuts.length - 1; k >= 0; k--) {
out = out.slice(0, cuts[k]) + out.slice(cuts[k] + 1);
}
return out;
}
/**
* Recover truncated JSON: close an unterminated string, drop a dangling
* comma, give a dangling colon a null value, then close every still-open
* bracket in reverse order.
*/
export function closeTruncated(text) {
let inString = false;
const stack = [];
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{' || ch === '[') stack.push(ch);
else if (ch === '}' || ch === ']') stack.pop();
}
let out = inString ? `${text}"` : text;
out = out.replace(/[\s,]+$/, '');
if (out.endsWith(':')) out += ' null';
return out + stack.reverse().map((c) => (c === '{' ? '}' : ']')).join('');
}
/** Pretty-print text that JSON.parse accepts (throws when it does not). */
function prettyPrint(text) {
return JSON.stringify(JSON.parse(text), null, 2);
}
/** Coerce a caught parse failure into a human-readable message. */
function errorMessage(value) {
return value instanceof Error ? value.message : String(value);
}
const PASSES = [
['Removed invisible characters (BOM / zero-width)', stripInvisible],
['Converted single quotes to double quotes', singleToDoubleQuotes],
['Stripped JavaScript comments', stripComments],
['Quoted unquoted keys', quoteUnquotedKeys],
['Converted Python constants (True/False/None)', fixPythonConstants],
['Removed trailing commas', stripTrailingCommas],
['Closed truncated brackets', closeTruncated],
];
/**
* Repair broken JSON and pretty-print the result. Never throws.
* - empty / whitespace-only / non-string → ok: false, "Input is empty"
* - input that already parses → same shape, changed: false, no fixes
* - repairable input → pretty text + a description per pass that fired
* - unrepairable input → ok: false, the engine's parse error
*/
export function repair(text) {
if (typeof text !== 'string' || text.trim().length === 0) {
return { text: '', fixes: [], changed: false, ok: false, error: 'Input is empty' };
}
// Already clean: pretty-print and say so.
try {
return { text: prettyPrint(text), fixes: [], changed: false, ok: true, error: null };
} catch {
// fall through to the repair passes
}
let work = text;
const fixes = [];
for (const [label, pass] of PASSES) {
const next = pass(work);
if (next !== work) {
fixes.push(label);
work = next;
}
}
try {
return { text: prettyPrint(work), fixes, changed: fixes.length > 0, ok: true, error: null };
} catch (e) {
return {
text: '',
fixes,
changed: fixes.length > 0,
ok: false,
error: errorMessage(e),
};
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →