altchecker/src/alt-text-checker.js

381 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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();
})();