-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
233 lines (210 loc) · 12.4 KB
/
Copy pathindex.html
File metadata and controls
233 lines (210 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Invisibles: find and remove hidden Unicode locally</title>
<meta name="description" content="Reveal zero-width spaces, look-alike whitespace, bidi controls, and other hidden Unicode in pasted text. Review and clean it locally in your browser.">
<meta property="og:title" content="Invisibles">
<meta property="og:description" content="See the invisible characters hiding in your text, then remove them. Runs in your browser.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://cig13zs.github.io/invisibles/">
<meta name="theme-color" content="#161613">
<link rel="canonical" href="https://cig13zs.github.io/invisibles/">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='25' font-size='24'>👁️</text></svg>">
<style>
/* shared token set, same in the popup and in every tool in the line */
:root{--bg:#faf9f6;--panel:#fff;--ink:#1c1a17;--muted:#6b665d;--line:#e4e0d8;--accent:#b4522f;--clay:#b4522f;--hit:#b3372c;--hit-soft:#f9eae8;--ok:#1f7a4d;--code:#f3f1ec}
@media (prefers-color-scheme:dark){:root{--bg:#14130f;--panel:#1c1a16;--ink:#f0ede5;--muted:#a09a8d;--line:#2e2a24;--accent:#e0865e;--clay:#e0865e;--hit:#ef7f70;--hit-soft:#2c1815;--ok:#54c489;--code:#221f1a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:680px;margin:0 auto;padding:0 24px}
a{color:var(--accent);text-decoration:underline;text-underline-offset:2px}
a:focus-visible,.btn:focus-visible,input:focus-visible{outline:2px solid var(--clay);outline-offset:2px}
.skip-link{position:fixed;left:16px;top:12px;z-index:10;padding:8px 12px;border-radius:8px;
background:var(--ink);color:var(--bg);text-decoration:none;transform:translateY(-160%)}
.skip-link:focus{transform:translateY(0)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
clip:rect(0,0,0,0);white-space:nowrap;border:0}
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em;background:var(--code);border:1px solid var(--line);border-radius:5px;padding:1px 5px}
header{padding:70px 0 4px}
h1{font-size:clamp(34px,6vw,50px);line-height:1.04;letter-spacing:-.03em;margin:0;font-weight:740}
.lede{font-size:clamp(17px,2.2vw,19px);color:var(--muted);margin:18px 0 0;max-width:33em}
.tool{margin:34px 0 0;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px}
.ta{width:100%;min-height:150px;resize:vertical;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink);padding:14px;font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.ta:focus{outline:2px solid var(--accent);outline-offset:1px}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px}
.btn{display:inline-flex;align-items:center;gap:7px;padding:11px 18px;border-radius:9px;font-weight:640;font-size:14.5px;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer}
.btn.primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}
.btn:disabled{opacity:.45;cursor:default}
.opt{display:inline-flex;align-items:center;gap:7px;font-size:13.5px;color:var(--muted);cursor:pointer;user-select:none}
.summary{margin-top:16px;font-size:14.5px}
.verdict{font-weight:700}
.verdict.clean{color:var(--ok)}
.verdict.dirty{color:var(--hit)}
.chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.chip{font-size:12.5px;border:1px solid var(--line);border-radius:999px;padding:4px 11px;background:var(--bg);color:var(--muted)}
.chip b{color:var(--ink)}
.reveal{margin-top:16px;padding:14px;border:1px dashed var(--line);border-radius:10px;background:var(--bg);font:14.5px/1.9 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word;max-height:280px;overflow:auto}
.reveal:empty{display:none}
.hit{display:inline-block;min-width:1.1em;text-align:center;background:var(--hit-soft);color:var(--hit);border:1px solid var(--hit);border-radius:4px;padding:0 3px;margin:0 1px;font-size:11px;font-weight:700;vertical-align:middle;cursor:help}
.hint{font-size:12.5px;color:var(--muted);margin-top:10px}
.out{margin-top:14px}
.out .ta{background:var(--code)}
section{padding:38px 0 0;margin-top:40px;border-top:1px solid var(--line)}
h2{font-size:20px;letter-spacing:-.01em;margin:0 0 12px;font-weight:680}
p{margin:0 0 14px}
.muted{color:var(--muted)}
dl{margin:18px 0 0}
dt{font-weight:640;font-size:14.5px;margin-top:14px}
dd{margin:3px 0 0;color:var(--muted);font-size:14px}
footer{padding:40px 0 72px;margin-top:44px;border-top:1px solid var(--line);color:var(--muted);font-size:14px}
.kofi{display:inline-block;padding:11px 20px;border-radius:9px;background:#FF5E5B;color:#fff;font-weight:640;font-size:14.5px;text-decoration:none}
@media (prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
</style>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<div class="wrap">
<header>
<h1>Invisibles</h1>
<p class="lede">Some text carries characters you can't see. Paste it below and this shows you
what's hiding, then strips it. It runs in your browser, so nothing gets sent anywhere.</p>
</header>
<main id="main">
<div class="tool">
<label class="sr-only" for="in">Text to inspect</label>
<textarea id="in" class="ta" placeholder="Paste your text here…" spellcheck="false" autocomplete="off"></textarea>
<div class="row">
<button id="clean" class="btn primary" type="button" disabled>Clean it</button>
<label class="opt"><input type="checkbox" id="norm"> also turn smart quotes and em dashes into plain ASCII</label>
</div>
<div class="summary" id="summary" role="status" aria-live="polite"></div>
<div class="chips" id="chips"></div>
<div class="reveal" id="reveal"></div>
<div class="out" id="outbox" hidden>
<label class="sr-only" for="out">Clean text</label>
<textarea id="out" class="ta" readonly spellcheck="false"></textarea>
<div class="row">
<button id="copy" class="btn primary" type="button">Copy clean text</button>
<span class="hint" id="delta" aria-live="polite"></span>
</div>
</div>
<p class="hint">All of this happens in the page with JavaScript. It makes no network requests.
Open your dev tools and check the network tab if you want to see for yourself.</p>
</div>
<section>
<h2>What goes wrong</h2>
<p class="muted">Hidden Unicode can make two strings look identical while comparing differently,
break field names in data imports, and alter how source code is displayed. Invisibles shows the
character, category, and code point so you can decide what to change.</p>
</section>
<section>
<h2>What it looks for</h2>
<dl>
<dt>Zero-width characters</dt>
<dd>Zero-width spaces and joiners, the word joiner, the byte-order mark. Completely invisible, and the ones that quietly break search and passwords.</dd>
<dt>Spaces that aren't spaces</dt>
<dd>Non-breaking space, the narrow no-break space, ideographic and thin spaces. They look like a normal space and aren't one.</dd>
<dt>Direction controls</dt>
<dd>Right-to-left overrides and isolates. These can reorder text on screen so it reads differently from what's stored, which is the basis of the Trojan Source trick.</dd>
<dt>Tag characters and variation selectors</dt>
<dd>Invisible characters that still change the stored Unicode sequence.</dd>
</dl>
<p class="muted" style="margin-top:16px">Look-alike spaces are replaced with normal spaces so
words don't run together. Other flagged characters are removed. The optional checkbox also
converts curly quotes, em dashes, and ellipses to plain ASCII when a target system needs it.</p>
</section>
<section>
<h2>It doesn't upload anything</h2>
<p class="muted">The scanner and cleaner run entirely in this page. There is no server-side text
processing, analytics, or account. You can download the source and run it offline.</p>
<p><a href="https://github.com/cig13zs/invisibles">Source on GitHub</a> if you'd rather run it yourself,
or grab the <a href="https://github.com/cig13zs/invisibles/releases">browser extension</a> so it's a click away from any tab.</p>
</section>
</main>
<footer>
<p style="margin-bottom:16px"><a class="kofi" href="https://ko-fi.com/jju1s" target="_blank" rel="noopener">Buy me a coffee</a></p>
<p>If it saved you a headache, <a href="https://ko-fi.com/jju1s">ko-fi.com/jju1s</a>
keeps me making these.</p>
<p style="font-size:12.5px;margin-top:16px">MIT licensed. Not affiliated with anyone.</p>
</footer>
</div>
<script src="core.js"></script>
<script>
(function () {
var I = window.Invisibles;
var inEl = document.getElementById('in'),
cleanBtn = document.getElementById('clean'),
normEl = document.getElementById('norm'),
summary = document.getElementById('summary'),
chips = document.getElementById('chips'),
reveal = document.getElementById('reveal'),
outbox = document.getElementById('outbox'),
outEl = document.getElementById('out'),
copyBtn = document.getElementById('copy'),
delta = document.getElementById('delta');
function esc(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
var SHORT = {'zero-width':'ZW','space':'SP','bidi':'BIDI','soft-hyphen':'SHY','tag-char':'TAG','variation-selector':'VS','control':'CTRL'};
var REVEAL_LIMIT = 20000; // past this, skip the per-char DOM build; counts still show
function render() {
var text = inEl.value;
var r = I.scan(text);
cleanBtn.disabled = !text || (r.total === 0 && !normEl.checked);
if (!text) { summary.innerHTML=''; chips.innerHTML=''; reveal.innerHTML=''; outbox.hidden=true; return; }
if (r.total === 0) {
summary.innerHTML = '<span class="verdict clean">Clean.</span> No hidden characters here.'
+ (normEl.checked ? ' Punctuation will still be normalized.' : '');
chips.innerHTML = ''; reveal.innerHTML = '';
return;
}
summary.innerHTML = '<span class="verdict dirty">' + r.total + ' hidden character' +
(r.total>1?'s':'') + '</span> found.';
var cats = Object.keys(r.counts).sort();
chips.innerHTML = cats.map(function(c){
var label = (I.CATEGORIES[c] || c);
return '<span class="chip" title="'+esc(label)+'"><b>'+r.counts[c]+'</b> '+esc(label.split(': ')[0])+'</span>';
}).join('');
if (text.length > REVEAL_LIMIT) {
reveal.innerHTML = '<span class="muted">Text too long to highlight inline, but the counts above are exact. Clean it to strip them.</span>';
return;
}
var marks = {}; r.findings.forEach(function(f){ marks[f.index]=f; });
var html = '', idx = 0;
while (idx < text.length) {
var cp = text.codePointAt(idx);
var f = marks[idx];
if (f) html += '<span class="hit" title="'+esc(f.name)+' ('+f.hex+')">'+(SHORT[f.category]||'?')+'</span>';
else html += esc(String.fromCodePoint(cp));
idx += cp > 0xFFFF ? 2 : 1;
}
reveal.innerHTML = html;
}
function doClean() {
var before = inEl.value, r = I.scan(before), t = I.clean(before);
if (normEl.checked) t = I.normalizePunctuation(t);
outEl.value = t;
outbox.hidden = false;
var swapped = r.counts.space || 0, deleted = r.total - swapped, parts = [];
if (deleted) parts.push(deleted + ' removed');
if (swapped) parts.push(swapped + ' turned into a normal space');
if (!parts.length) delta.textContent = normEl.checked ? 'Punctuation normalized.' : 'Nothing to change.';
else delta.textContent = parts.join(', ') + '.' + (normEl.checked ? ' Punctuation normalized.' : '');
outEl.style.height = 'auto';
outEl.style.height = Math.min(outEl.scrollHeight + 4, 320) + 'px';
}
copyBtn.addEventListener('click', function () {
outEl.select();
navigator.clipboard.writeText(outEl.value).then(function(){
var old = copyBtn.textContent; copyBtn.textContent = 'Copied';
setTimeout(function(){ copyBtn.textContent = old; }, 1400);
}).catch(function(){ document.execCommand('copy'); });
});
inEl.addEventListener('input', render);
normEl.addEventListener('change', render);
cleanBtn.addEventListener('click', doClean);
render();
})();
</script>
</body>
</html>