Cat Toolbox
Help CatBlog About Partnership 中文

JSON, Escape and Markdown Tools: What These Four Pages Do to Your Text Without Asking

FormatUpdated 2026-09-30

Four pages, one shared habit. You give them text, they change it, and the screen looks exactly the same as if nothing had happened. Most of those changes are reasonable — two-space indentation is a fine default, and & has to become & before a browser will show it. A few of them will hand you a wrong answer that is one character different from the right one, which is the worst kind of wrong.

Everything below was measured on the English pages: the JSON formatter and the code generator driven with purpose-built inputs, the escaper round-tripped through nine strings, and the Markdown preview inspected through the DOM it produced — including the two cases where a link URL turns into executable code.

The JSON formatter is JSON.stringify, with all of that implied

Format runs your text through JSON.parse and then JSON.stringify(obj, null, 2). Compress is the same with the indent argument removed. That single fact decides most of what this page can and cannot do:

You paste You get Why
{"b":1,"a":2} "b" first key order is whatever the parser stored, not sorted
{"a":1,"a":2} "a": 2 a JS object cannot hold the key twice; the last write wins
1e309 null, with a green "JSON formatted successfully!" it parses to Infinity, which JSON has no way to write
12345678901234567890 12345678901234567000 doubles keep about 17 significant digits
{"a":"\u00e9"} {"a":"é"} escape sequences are decoded, not preserved
{"a":0.1,"b":0.2} unchanged each number round-trips on its own; no arithmetic is done
// comment an error JSON5, comments and single quotes are not accepted
null null a top-level scalar is valid JSON

There is no indent selector. The output is always two spaces, and the copy button puts exactly that on the clipboard.

The precision row is the one that bites. If your JSON contains IDs that came from a Python int, a Java long, a Snowflake ID or a database bigint, they arrive here as digits and leave as approximations. 12345678901234567890 becomes 12345678901234567000 — the last three digits are simply invented, and the result is still perfectly valid JSON, so a round trip through this page is enough to corrupt an ID. Nothing on the page warns you. Keep big integers as quoted strings: measured on {"id":"12345678901234567890"}, the digits come out exactly as they went in.

The two failure paths behave differently

The page distinguishes three outcomes, and only two of them are what you would guess:

  • Blank or whitespace-only input — the red banner reads Please paste some JSON text first. Your previous output stays in the box untouched.
  • Invalid JSON — the banner reads Invalid JSON: followed by the engine's own message, for example Expected property name or '}' in JSON at position 1 (line 1 column 2). The output box is emptied and the counter drops to Characters: 0.
  • Valid JSON — a green banner, JSON formatted successfully! or JSON compressed successfully!, and the counter shows the output length.

One detail to know before you go hunting for the banner: the moment you type in the input box, both banners disappear — the input field has a listener that hides them. So after you fix one character and re-press Format, the panel looks clean with no trace of the earlier failure. If your output box went empty and there is no message anywhere, that is what happened; press Format again.

The message itself comes straight from the JavaScript engine, so it is precise about position but phrased for programmers. Four inputs cover most of the rejections this page ever gives:

You paste The banner says
{"a":1,} Expected double-quoted property name in JSON at position 7
{'a':1} Expected property name or '}' in JSON at position 1 (line 1 column 2)
// c on the line above the object Unexpected token '/', "// c⏎{"a":1}" is not valid JSON
{"a":NaN} Unexpected token 'N', "{"a":NaN}" is not valid JSON

Trailing comma, single quotes, a comment, and a token your own language happily printed but JSON does not define. Note that the last two messages quote the beginning of your text back at you — the error line is written with textContent, so your pasted text is displayed rather than executed, but it does appear on screen.

The counter under the output box counts JavaScript characters, not bytes, and not the input you pasted. 中文中文中文 counts six. On the wire, in the UTF-8 that every HTTP client will charge you for, those six characters are eighteen bytes. Measured on the payload {"a":"中文中文中文"}, the input is 14 characters and 26 bytes; the formatted output reports 19. So the number here is neither your file size nor your request size — use it to compare your own two candidates, not to fit a limit.

The code generator has two languages and one naming rule

JSON to Code offers exactly two targets: TypeScript interface and Python dict. There are no options — no root name field, no semicolon toggle, no "generate types for every object", no type versus interface.

For TypeScript the rules are:

  • The top-level object is always interface Root { … }. You cannot rename it.
  • A nested object is named after its key, converted to PascalCase by dropping the separators: measured, "http_response_code" → interface HttpResponseCode, "x y" → interface XY.
  • An array of objects gets Item appended: {"list":[{"id":1}]} → list: ListItem[].
  • Property names are written exactly as they appear in the JSON, not sanitised.

That last rule is what breaks real payloads. API responses are full of keys that are not legal TypeScript identifiers:

{"my_key":{"p":1},"my key":{"q":1}}
interface Root {
  my_key: MyKey;
  my key: MyKey;
}

interface MyKey {
  p: number;
}

interface MyKey {
  q: number;
}

Two invalid lines and two conflicting definitions, with no warning. Case-only differences do the same thing more quietly: {"a":{"x":1},"A":{"x":"s"}} produces interface A twice with different bodies, which reads as valid TypeScript until the compiler disagrees. Rename one of the colliding keys in your source, or edit the generated file by hand — the page will not do it for you.

Arrays are typed from their first element only. {"xs":[1,"a",null,{}]} gives xs: number[], and [true,"a"] gives boolean[]. The generator does not look past element zero, so a mixed array is reported as homogeneous. When there is nothing to read — [], or a first element of null — it falls back to xs: any[], which type-checks everything and therefore protects nothing. Nested arrays do work: [[1,2],[3]] comes out as number[][].

A top-level array is not treated as a list at all. [{"a":1},{"a":2}] is expanded into indexed properties, 0: _0 and 1: _1, with two interfaces named _0 and _1 under them. Wrap the array in an object, or write that one by hand.

A scalar or string at the top level is not an object, so there is nothing to describe: 42 yields interface Root { } with no fields — it parses, it prints, and the number is simply gone. "hi" is worse, because a string has indexed properties: you get 0: string; 1: string; for a two-character string.

On error, this page keeps your previous output. Please enter JSON text for an empty box, JSON parse error: … for bad JSON, and whatever was in the box before stays there, ready to be copied by mistake. Different from the formatter, which clears.

Python mode prints None, but not True

The Python target is a literal writer, not a Python writer. Measured output for {"a":1,"b":"x","c":true,"d":null,"e":[1,2],"f":{"g":1},"g":1.5}:

data = {
    "a": 1,
    "b": "x",
    "c": true,
    "d": None,
    "e": [
        1,
        2
    ],
    "f": {
        "g": 1
    },
    "g": 1.5
}

Indentation is four spaces here and two in TypeScript mode. null correctly becomes None, but true and false stay lower-case — in Python that is a NameError, not a boolean. Numbers beyond double range become Infinity, which Python also does not have (it is float('inf')). And the top level is assumed to be an object: [1,"a"] arrives as data = {"0": 1, "1": "a"} — a dict with string keys, not a list — while 42 becomes data = {} with the value dropped.

Use this mode the way you would use a syntax highlighter: to see the shape of a payload. Paste it into a .py file only after you have searched for true, false and Infinity.

The HTML escaper knows five characters

Escape replaces &, <, >, " and ', in that order. Unescape reverses those five and nothing else. Everything outside that set passes through untouched — which is both the point and the trap.

Measured, on this exact input:

Input Escaped Unescaped
a & b a &amp; b a & b
<div class="x"> &lt;div class=&quot;x&quot;&gt; <div class="x">
"q" 'r' &quot;q&quot; &#39;r&#39; "q" 'r'
&nbsp;&copy;&eacute; &amp;nbsp;&amp;copy;&amp;eacute; &nbsp;&copy;&eacute;
&#x27;&amp; &amp;#x27;&amp;amp; &#x27;&
&amp;lt;div&amp;gt; &amp;amp;lt;div&amp;amp;gt; &lt;div&gt;
© ü © ü © ü

Read the &amp;lt;div&amp;gt; row twice, because it is the one that costs people time. One press of Unescape strips one layer: &amp;lt; becomes &lt;, not <. But pressing it a second time changes nothing — both buttons read the input box and write the output box, so the result is idempotent no matter how many times you click. Measured on &amp;lt;p&amp;gt;: first press gives &lt;p&gt;, second press gives &lt;p&gt;, and only after you press Swap does a third press give <p>. So depth is controlled by Swap, not by clicking harder. The same works in the other direction: <b>&amp;</b> escaped twice is still &lt;b&gt;&amp;amp;&lt;/b&gt;, and Escape after Swap produces the doubled &amp;lt;b&amp;gt;&amp;amp;amp;&amp;lt;/b&amp;gt;. This page will happily escape text that was already escaped — &nbsp; turns into &amp;nbsp; and displays as the literal text &nbsp; on your site instead of a space — so if you are pasting from a CMS that already escaped on save, escaping again here is the bug, not the fix.

The named entities it does not recognise (&copy;, &eacute;, &ndash;, &#x27;) are left alone in both directions. It is not an entity decoder; it is a five-character tag-safe filter. Use it for pasting code into a blog post or a comment box, not for un-mangling a whole document.

One cosmetic note: the counter under the output box starts out reading Characters: 0 and switches to Character count: N the first time you press anything. Same number, two labels.

The Markdown preview is not a Markdown renderer

The Markdown preview page updates as you type — there is no button and nothing is uploaded. What it does is a chain of nine regular expressions applied to your text, and the result assigned with innerHTML. It is a Markdown-shaped highlighter, and the differences are worth listing precisely because its behaviour on unsupported syntax is silent:

You type You see Actually produced
# T, ## T, ### T headings <h1>, <h2>, <h3>
#### four, ###### six the literal text no h4–h6 in the chain
**b**, *i*, `c` bold, italic, code <strong>, <em>, <code>
> quote blockquote <blockquote> per line
- item a bullet <li> with no <ul> wrapper
1. one 1. one ordered lists are not handled
--- --- horizontal rules are not handled
![alt](/a.png) ! then a link image syntax is not handled
tables, task lists, footnotes literal text not handled
```js … ``` an empty code chip the three backticks pair up as empty inline code

Every line break is turned into <br> and nothing else, so the preview has no paragraphs at all. Measured on a five-line sample, # T, a blank line, two sentences, a blank line, a quote, a blank line and a bullet, the DOM comes out as:

<h1>T</h1><br><br>para one line<br>para two<br><br><blockquote>quote</blockquote><br><br><li>item</li>

Two <br> for a blank line, one for a plain line break, and a bare <li> that no <ul> ever claims. A nested list (- b under - a) loses the indent entirely: the second line's leading spaces survive and its - is not recognised, so it prints as text.

The important one is what happens to HTML you type. Nothing. This renderer does not escape angle brackets, so <b>bold</b> in your Markdown comes out as actual bold text, and anything tag-shaped is inserted as markup. A stray < followed by a space (5 < 6) survives as text because the browser's parser cannot read it as a tag, but that is the parser rescuing you, not the page.

Which has a concrete consequence for links. The link rule is:

text.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2">$1</a>')

$2 is dropped inside a double-quoted attribute with no escaping of its own. So a double quote in the URL does not stay in the URL — it closes the attribute and everything after it becomes markup. Writing this:

[click](x" onclick="window.__c=1)

produces exactly this in the preview:

<a href="x" onclick="window.__c=1">click</a>

and the injected handler is live: in the test, clicking the link ran the code. The same hole exists on the Chinese version of this page, which does escape < and > first but leaves " alone. No scheme filter either — [link](javascript:window.__j=1) yields an anchor whose href really is that javascript: URL.

Treat the preview box as "renders anything you paste". If you are previewing Markdown written by someone else — a README from a repo, an issue body, a .md file you found — this page will run what it contains. It is a preview of your own text only.

Which page for which job

You have Use Watch out for
Minified JSON to read json-formatter big integers get rounded, Infinity becomes null
JSON to put back on the wire json-formatter's Compress key order follows the parse, not your file
A response shape to type by hand json-to-code, TypeScript keys with spaces, case collisions, mixed arrays
A payload to eyeball in a notebook json-to-code, Python true/false/Infinity are not Python
Code to paste into an HTML page html-escape never run it on text that is already escaped
Entities back to characters html-escape's Unescape five entities only, one layer per pass, and a pass needs Swap to go deeper
Your own notes as HTML markdown-preview nine syntax items, no tables, live HTML
Markdown written by a stranger none of them the preview executes what it renders

FAQ

Why did my 1e309 turn into null? Because JSON.parse gives you Infinity, and JSON.stringify has no token for it, so it writes null — and the page then reports success. Check the output for null where you expect a number.

Did the formatter really change my ID? Yes, if it was longer than about 17 significant digits. The number is stored as a double before it is printed. Quote it and it survives untouched; leave it bare and it may not.

My TypeScript output has two interfaces with the same name. Is that my JSON's fault? It is the JSON's shape and the page's naming rule together. Keys are turned into PascalCase names, and a and A — or my_key and my key — collapse to one name. Edit the output by hand, or rename the colliding key in the source.

Why does [1,"a"] say number[]? The array type is decided by element zero. Anything after it is never examined. Mixed arrays need a hand-written union type.

I pressed Unescape twice and the text did not change the second time. Is it broken? No, that is how it works. Both buttons take their text from the input box and write to the output box, so the second press recomputes the same answer. To peel a second layer, press Swap in between — but note what Swap does: it transposes the two boxes, so immediately after it the output pane holds your original raw text, not a result, until you press Escape or Unescape again. Double-escaped input needs one Swap per extra layer.

&nbsp; came back as &amp;nbsp;. Is that a bug? It is the documented behaviour of an escaper that treats & as a character to escape. The page has no way to know whether &nbsp; was meant as an entity or as text, and it always chooses "text".

Why does the Markdown preview show my HTML tags instead of escaping them? Because it never escapes — it assigns the result straight to innerHTML. That is also why a " inside a link URL can add an attribute to the generated <a>, and why pasting someone else's Markdown into it is not safe.

Is any of this uploaded? No. All four pages parse and rewrite in the tab you are typing in: JSON.parse/JSON.stringify, string replacement and innerHTML, with no network call anywhere in their scripts. The Markdown page even keeps its preview in the same document. The trade-off is that the preview runs whatever markup it is given, locally.

Tools used in this guide

Advertisement