wa-dev-tools/public/sanitizer.html
treamz 89345551ef Unified headers: centered with badge tags on all 12 tool pages
- All pages: centered header with animated badge (format tags)
- Badge format: colored dot + tech tags (PNG·JPG·WebP etc)
- Updated descriptions to be more detailed
- Consistent style across compress, video, http, redirects, password,
  pdf, parser, md, sanitizer, converter, formatter, placeholder
2026-03-22 10:45:38 +03:00

227 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HTML Sanitizer — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<script>window.WA_TOOL_ID = 'sanitizer';</script>
<script src="/shared.js?v=2"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<style>
select,select option{background:var(--select-bg);color:var(--select-color)}
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)}
.sub-btn.active{color:#0054e6;background:rgba(0,84,230,.12);border-color:rgba(0,84,230,.3)}
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<div class="main-content">
<div class="tool-container relative z-10 max-w-3xl mx-auto px-4 py-8 sm:py-12">
<div class="page-header" style="text-align:center; mb-8 fade-up">
<div class="inline-flex items-center gap-2 mb-3 px-3 py-1 rounded-full border dark:border-surface-600 border-gray-300 dark:bg-surface-800/60 bg-white/60 text-xs font-mono dark:text-gray-400 text-gray-500 tracking-wide">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
XSS · Entities · Minify
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
HTML <span class="text-accent">Sanitizer</span>
</h1>
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">Очистка, извлечение текста, кодирование entities и минификация</p>
</div>
<!-- Tool Grid -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-5 fade-up-delay">
<button class="tool-btn active px-3 py-2.5 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="sanitize">
<div class="text-xs font-semibold">Sanitize</div>
<div class="text-[10px] dark:text-gray-600 text-gray-400 mt-0.5">XSS cleanup</div>
</button>
<button class="tool-btn px-3 py-2.5 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="htmlToText">
<div class="text-xs font-semibold">HTML &rarr; Text</div>
<div class="text-[10px] dark:text-gray-600 text-gray-400 mt-0.5">strip tags</div>
</button>
<button class="tool-btn px-3 py-2.5 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="entities">
<div class="text-xs font-semibold">Entities</div>
<div class="text-[10px] dark:text-gray-600 text-gray-400 mt-0.5">encode / decode</div>
</button>
<button class="tool-btn px-3 py-2.5 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="minify">
<div class="text-xs font-semibold">Minify</div>
<div class="text-[10px] dark:text-gray-600 text-gray-400 mt-0.5">compress html</div>
</button>
</div>
<!-- Processing Card -->
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl p-5 sm:p-7 backdrop-blur-sm fade-up-delay shadow-sm dark:shadow-none">
<div id="entityOpts" class="flex gap-2 mb-4 hidden">
<button class="sub-btn active flex-1 px-3 py-2 rounded-lg text-xs font-medium border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 transition-colors cursor-pointer" data-sub="encode">Encode</button>
<button class="sub-btn flex-1 px-3 py-2 rounded-lg text-xs font-medium border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 transition-colors cursor-pointer" data-sub="decode">Decode</button>
</div>
<div class="mb-4">
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Input</label>
<textarea id="inputArea" rows="8" placeholder="Вставьте HTML для очистки..."
class="w-full px-4 py-3 rounded-xl dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 dark:text-gray-200 text-gray-700 focus:outline-none focus:border-accent/50 transition-colors"></textarea>
</div>
<button id="processBtn"
class="w-full py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
bg-accent text-white border border-accent cursor-pointer
hover:bg-accent-bright hover:shadow-lg hover:shadow-accent/20 active:scale-[0.98]">
Очистить
</button>
<div id="outputBlock" class="mt-5 hidden">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-medium dark:text-gray-500 text-gray-400 font-mono uppercase tracking-wider">Output</label>
<div class="flex items-center gap-2">
<span id="stats" class="text-[10px] font-mono dark:text-gray-600 text-gray-400"></span>
<button id="copyBtn" class="px-3 py-1 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">Копировать</button>
<button id="clearBtn" class="px-3 py-1 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-400 text-gray-500 hover:text-danger transition-colors border dark:border-surface-600 border-gray-200">Очистить</button>
</div>
</div>
<textarea id="outputArea" rows="10" readonly
class="w-full px-4 py-3 rounded-xl dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 dark:text-gray-200 text-gray-700 focus:outline-none transition-colors"></textarea>
</div>
</div>
</div>
</div>
<script>
const TOOLS = {
sanitize: { btn: 'Очистить', ph: 'Вставьте HTML для очистки...' },
htmlToText: { btn: 'Извлечь текст', ph: 'Вставьте HTML...' },
entities: { btn: 'Конвертировать', ph: 'Вставьте текст или HTML entities...', hasSub: true },
minify: { btn: 'Минифицировать', ph: 'Вставьте HTML...' },
};
let activeTool = 'sanitize';
let entityMode = 'encode';
const $ = id => document.getElementById(id);
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn[data-tool]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeTool = btn.dataset.tool;
const t = TOOLS[activeTool];
$('processBtn').textContent = t.btn;
$('inputArea').placeholder = t.ph;
$('entityOpts').classList.toggle('hidden', !t.hasSub);
});
});
document.querySelectorAll('.sub-btn[data-sub]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.sub-btn[data-sub]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
entityMode = btn.dataset.sub;
});
});
$('processBtn').addEventListener('click', () => {
const input = $('inputArea').value;
if (!input.trim()) return;
let output = '';
try {
switch (activeTool) {
case 'sanitize': output = sanitizeHtml(input); break;
case 'htmlToText': output = htmlToText(input); break;
case 'entities': output = entityMode === 'encode' ? entityEncode(input) : entityDecode(input); break;
case 'minify': output = minifyHtml(input); break;
}
} catch (e) { output = 'Error: ' + e.message; }
$('outputArea').value = output;
$('outputBlock').classList.remove('hidden');
const diff = output.length - input.length;
$('stats').textContent = `${input.length} \u2192 ${output.length} (${diff > 0 ? '+' : ''}${diff})`;
});
$('copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText($('outputArea').value).then(() => {
const b = $('copyBtn'); const o = b.textContent;
b.textContent = '\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u043e!'; setTimeout(() => { b.textContent = o; }, 1500);
});
});
$('clearBtn').addEventListener('click', () => {
$('inputArea').value = ''; $('outputArea').value = ''; $('outputBlock').classList.add('hidden');
});
// ==================== Sanitize ====================
function sanitizeHtml(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const SAFE = new Set(['p','br','hr','b','i','em','strong','u','s','del','ins','a','img','ul','ol','li','dl','dt','dd','h1','h2','h3','h4','h5','h6','blockquote','pre','code','kbd','samp','var','table','thead','tbody','tfoot','tr','th','td','caption','figure','figcaption','span','div','section','article','aside','header','footer','main','nav','sub','sup','mark','small','abbr','time','details','summary']);
const ATTRS = new Set(['href','src','alt','title','class','id','width','height','target','rel','colspan','rowspan','datetime','lang','dir','loading','srcset','sizes']);
const KILL = new Set(['script','style','iframe','object','embed','applet','form','input','textarea','select','button','link','meta','base','noscript']);
(function clean(parent) {
for (const node of Array.from(parent.childNodes)) {
if (node.nodeType !== 1) continue;
const tag = node.tagName.toLowerCase();
if (KILL.has(tag)) { node.remove(); continue; }
if (!SAFE.has(tag)) {
while (node.firstChild) parent.insertBefore(node.firstChild, node);
node.remove(); continue;
}
for (const attr of Array.from(node.attributes)) {
if (attr.name.startsWith('on') || !ATTRS.has(attr.name)) { node.removeAttribute(attr.name); continue; }
if ((attr.name === 'href' || attr.name === 'src') && /^(javascript|vbscript|data:text\/html)/i.test(attr.value.trim())) {
node.removeAttribute(attr.name);
}
}
if (tag === 'a' && node.getAttribute('target')) node.setAttribute('rel', 'noopener noreferrer');
clean(node);
}
})(doc.body);
return doc.body.innerHTML;
}
// ==================== HTML → Text ====================
function htmlToText(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
function walk(node) {
if (node.nodeType === 3) return node.textContent;
if (node.nodeType !== 1) return '';
const tag = node.tagName.toLowerCase();
if (tag === 'script' || tag === 'style') return '';
const inner = Array.from(node.childNodes).map(walk).join('');
switch (tag) {
case 'br': return '\n';
case 'hr': return '\n---\n';
case 'p': case 'div': case 'section': case 'article': return '\n\n' + inner.trim() + '\n\n';
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return '\n\n' + inner.trim() + '\n\n';
case 'li': return '\n - ' + inner.trim();
case 'ul': case 'ol': return '\n' + inner + '\n';
case 'blockquote': return '\n> ' + inner.trim().split('\n').join('\n> ') + '\n';
case 'pre': return '\n\n' + node.textContent + '\n\n';
case 'tr': return inner + '\n';
case 'td': case 'th': return inner + '\t';
default: return inner;
}
}
return walk(doc.body).replace(/\n{3,}/g, '\n\n').trim();
}
// ==================== Entities ====================
function entityEncode(text) {
return text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function entityDecode(text) {
const el = document.createElement('textarea');
el.innerHTML = text;
return el.value;
}
// ==================== Minify ====================
function minifyHtml(html) {
return html.replace(/<!--[\s\S]*?-->/g,'').replace(/\s+/g,' ').replace(/>\s+</g,'><').replace(/\s+>/g,'>').replace(/<\s+/g,'<').trim();
}
</script>
</body>
</html>