/** * Alt-Text Checker – Bookmarklet * Prüft Bilder auf einer Webseite auf Barrierefreiheit (Alt-Text, WCAG 2.1). * Läuft komplett lokal im Browser, keine Datenübertragung. * * Sicherheitshinweis: Attributwerte von der besuchten Seite (alt, title, IDs, …) * gelten als nicht vertrauenswürdig. Sie werden daher ausschließlich über * textContent / DOM-Knoten eingefügt, niemals über innerHTML mit String-Konkatenation. */ (function () { 'use strict'; const NS = 'atc'; // Namespace für CSS-Klassen const STORAGE_PREFIX = 'alt-text-checker:excluded:'; const COLORS = { success: { bg: 'lime', outline: '#000', text: '#000' }, error: { bg: '#EB0000', outline: '#fff', text: '#fff' }, warning: { bg: 'orange', outline: '#000', text: '#000' }, excluded:{ bg: '#e0e0e0', outline: '#666', text: '#333' }, info: { bg: 'yellow', outline: '#000', text: '#000' } }; // Immer sichtbarer Text neben dem Icon – Icons allein sind für Screenreader // nicht eindeutig (z.B. wird ✔ als "Häkchen" statt "OK" vorgelesen). const BADGE_TEXT = { success: 'OK', error: 'FEHLER', warning: 'WARNUNG', excluded: 'AUSGESCHLOSSEN', info: 'INFO' }; const BASE_LABEL_STYLE = { 'font-family': 'sans-serif', 'font-size': 'small', padding: '5px 8px', outline: '2px dotted #000', 'outline-offset': '-3px', border: '0', cursor: 'default', 'z-index': '2147483647' }; // --------------------------------------------------------------------- // Speicherung der Ausschlüsse (pro Seiten-URL, dauerhaft im localStorage) // --------------------------------------------------------------------- function storageKey() { return STORAGE_PREFIX + location.href; } function loadExclusions() { try { const raw = localStorage.getItem(storageKey()); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function saveExclusions(list) { try { localStorage.setItem(storageKey(), JSON.stringify(list)); } catch (e) { // localStorage evtl. nicht verfügbar (z.B. privater Modus) – ignorieren } } function imageIdentifier(img) { // src ist die stabilste Kennung eines Bildes über mehrere Seitenaufrufe hinweg if (img.tagName === 'IMG') return img.currentSrc || img.src || null; // Nicht--Elemente mit role="img" (z.B. Inline-) haben kein src. // Fallback: umgebender Link oder aria-label als Kennung. const link = img.closest ? img.closest('a[href]') : null; if (link && link.href) return `role-img:${link.href}`; const label = img.getAttribute('aria-label'); if (label) return `role-img:${label}`; return null; } function isExcluded(img, exclusions) { const id = imageIdentifier(img); return id !== null && exclusions.includes(id); } function toggleExclusion(img) { const id = imageIdentifier(img); if (id === null) return; const exclusions = loadExclusions(); const idx = exclusions.indexOf(id); if (idx === -1) exclusions.push(id); else exclusions.splice(idx, 1); saveExclusions(exclusions); } // --------------------------------------------------------------------- // Hilfsfunktionen // --------------------------------------------------------------------- // Durchsucht auch offene Shadow-DOM-Bäume function deepQueryAll(selector, root, callback) { let count = 0; Array.from((root || document).querySelectorAll(selector)).forEach((el) => { callback(el); count += 1; }); Array.from((root || document).querySelectorAll('*')).forEach((el) => { if (el.shadowRoot) count += deepQueryAll(selector, el.shadowRoot, callback); }); return count; } function styleToString(styleObj) { return Object.keys(styleObj) .map((key) => `${key}:${styleObj[key]}`) .join(';'); } function isInsideLink(el) { return !!(el.parentNode && (el.parentNode.tagName === 'A' || isInsideLink(el.parentNode))); } function removePreviousRun() { deepQueryAll(`.${NS}`, document, (el) => el.remove()); } // Baut sicher einen DOM-Fragment aus Text- und Markup-Segmenten auf. // Strings = reiner (von uns erzeugter) Text. {code:} / {strike:} = Wert wird // per textContent in / gesetzt – niemals per innerHTML. function buildFragment(segments) { const frag = document.createDocumentFragment(); segments.forEach((seg) => { if (typeof seg === 'string') { frag.appendChild(document.createTextNode(seg)); } else if (seg.code !== undefined) { const code = document.createElement('code'); code.textContent = seg.code; frag.appendChild(code); } else if (seg.strike !== undefined) { const s = document.createElement('s'); s.textContent = seg.strike; frag.appendChild(s); } }); return frag; } // --------------------------------------------------------------------- // Label-Erstellung (Badge neben dem geprüften Element) // --------------------------------------------------------------------- // content: bei opts.global ein reiner String (von uns generiert), // sonst ein Array von Segmenten für buildFragment(). function createLabel(el, kind, content, opts) { opts = opts || {}; const colors = COLORS[kind]; const isGlobal = !!opts.global; const wrapper = document.createElement(isGlobal ? 'label' : 'button'); wrapper.className = `${NS} ${NS}-${kind}`.trim(); const style = Object.assign({}, BASE_LABEL_STYLE, { 'background-color': colors.bg, 'outline-color': colors.outline, color: colors.text }); if (opts.position === 'absolute' && el.offsetParent && el.offsetParent !== document.body) { style.left = `${el.offsetLeft}px`; style.top = `${el.offsetTop}px`; } if (opts.extraStyle) Object.assign(style, opts.extraStyle); wrapper.setAttribute('style', styleToString(style)); const icon = opts.icon || ''; if (isGlobal) { wrapper.textContent = icon ? `${icon} ${content}` : content; } else { wrapper.appendChild(document.createTextNode(icon)); const badge = document.createElement('span'); badge.className = `${NS}-badge`; badge.textContent = BADGE_TEXT[kind] || ''; badge.style.marginLeft = '4px'; badge.style.fontWeight = '700'; wrapper.appendChild(badge); const details = document.createElement('span'); details.className = `${NS}-details`; details.appendChild(buildFragment(content)); details.style.color = 'inherit'; details.style.marginLeft = '4px'; wrapper.appendChild(details); if (!opts.alwaysOpen) { details.style.display = 'none'; const show = () => { if (document.querySelectorAll(`.${NS}-toggle:checked`).length === 0) { details.style.display = 'inline'; } }; const hide = () => { if (document.querySelectorAll(`.${NS}-toggle:checked`).length === 0) { details.style.display = 'none'; } }; wrapper.addEventListener('focus', show); wrapper.addEventListener('mouseover', show); wrapper.addEventListener('blur', hide); wrapper.addEventListener('mouseout', hide); } } if (opts.onClick) { wrapper.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); opts.onClick(); }); } if (isGlobal && opts.withToggle) { wrapper.appendChild(document.createTextNode(' — alle Details anzeigen')); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = `${NS}-toggle`; checkbox.style.margin = '0 0 0 4px'; checkbox.style.verticalAlign = 'baseline'; checkbox.addEventListener('change', function () { document.querySelectorAll(`.${NS}-details`).forEach((d) => { d.style.display = this.checked ? 'inline' : 'none'; }); }); wrapper.appendChild(checkbox); } if (isGlobal) { document.querySelectorAll(`.${NS}-global`).forEach((n) => n.remove()); wrapper.className += ` ${NS}-global`; } if (opts.position === 'absolute' && el.parentNode) { el.parentNode.insertBefore(wrapper, el.nextSibling); } else { (opts.insertInto || el.parentNode).appendChild(wrapper); } return wrapper; } // --------------------------------------------------------------------- // Prüf-Logik: referenzierten Text auflösen (z.B. aria-describedby) // --------------------------------------------------------------------- // Gibt den (zusammengefügten) sichtbaren Text der referenzierten Elemente // zurück, oder null falls das Attribut fehlt / nichts gefunden wurde. // Markiert die referenzierten Elemente zusätzlich selbst auf der Seite. function resolveIdRefs(attrName, imgEl) { const value = imgEl.getAttribute(attrName); if (!value) return null; const ids = value.split(' ').map((s) => s.trim()).filter(Boolean); const texts = []; ids.forEach((id) => { const ref = document.getElementById(id); if (!ref) return; createLabel( ref, 'warning', [`Referenziert von [${attrName}="`, { code: imgEl.id || '…' }, '"]'], { icon: '⚠️', position: 'absolute' } ); const refText = ref.textContent.trim(); if (refText) texts.push(refText); }); return texts.length ? texts.join(' ') : null; } // --------------------------------------------------------------------- // Hauptanalyse // --------------------------------------------------------------------- function run() { removePreviousRun(); const exclusions = loadExclusions(); const stats = { ok: 0, error: 0, warning: 0, excluded: 0, total: 0 }; // Fehlerhafte Verwendung von alt auf Nicht--Elementen deepQueryAll('a[alt], button[alt], label[alt]', document, (el) => { createLabel( el, 'error', [`<${el.tagName.toLowerCase()}> mit ungültigem `, { code: `alt="${el.getAttribute('alt')}"` }], { icon: '✖', position: 'absolute' } ); }); deepQueryAll('img, [role="img"]', document, (img) => { stats.total += 1; if (isExcluded(img, exclusions)) { stats.excluded += 1; createLabel(img, 'excluded', ['Ausgeschlossen (manuell als dekorativ markiert)'], { icon: '⦸', position: 'absolute', onClick: () => { toggleExclusion(img); run(); } }); return; } const hasAlt = img.hasAttribute('alt'); const altValue = hasAlt ? img.getAttribute('alt') : null; const hasAriaLabel = img.hasAttribute('aria-label'); const describedBy = resolveIdRefs('aria-describedby', img); const labelledBy = resolveIdRefs('aria-labelledby', img); const hasTitle = img.hasAttribute('title'); const hasLongdesc = img.hasAttribute('longdesc'); const hasAccessibleName = hasAriaLabel || describedBy !== null || labelledBy !== null; const linkPrefix = isInsideLink(img) ? 'Bild in Link: ' : ''; const extras = []; if (hasTitle) extras.push(' ', { code: `title="${img.getAttribute('title')}"` }); if (hasLongdesc) extras.push(' ', { code: `longdesc="${img.getAttribute('longdesc')}"` }); let segments; let kind; if (hasAlt) { const isDecorative = altValue.trim().length === 0; segments = [linkPrefix, { code: `alt="${altValue}"` }, ...extras]; kind = isDecorative ? 'warning' : 'success'; } else if (hasAccessibleName) { const resolvedName = hasAriaLabel ? img.getAttribute('aria-label') : (labelledBy || describedBy); segments = [ hasTitle ? '' : linkPrefix, { strike: 'alt' }, ' fehlt, aber ARIA-Name vorhanden: ', { code: resolvedName }, ...extras ]; kind = 'warning'; } else { segments = [linkPrefix, { strike: 'alt' }, ' fehlt', ...extras]; kind = 'error'; } stats[kind === 'success' ? 'ok' : kind] += 1; createLabel(img, kind, segments, { icon: kind === 'success' ? '✔' : kind === 'error' ? '✖' : '⚠️', position: 'absolute', onClick: () => { toggleExclusion(img); run(); } }); }); const summaryKind = stats.error > 0 ? 'error' : stats.warning > 0 ? 'warning' : 'success'; const summaryText = stats.total ? `Bilder: ${stats.total} · ✔ ${stats.ok} OK · ✖ ${stats.error} Fehler · ⚠️ ${stats.warning} Warnung · ⦸ ${stats.excluded} ausgeschlossen` : 'Keine Bilder auf dieser Seite gefunden.'; createLabel(document.body, stats.total ? summaryKind : 'info', summaryText, { global: true, withToggle: !!stats.total, extraStyle: { position: 'fixed', top: '0', left: '0' } }); } run(); })();