381 lines
13 KiB
JavaScript
381 lines
13 KiB
JavaScript
/**
|
||
* 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-<img>-Elemente mit role="img" (z.B. Inline-<svg>) 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 <code>/<s> 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-<img>-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();
|
||
})();
|