Compare commits

...

No commits in common. "pages" and "main" have entirely different histories.
pages ... main

11 changed files with 736 additions and 111 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "static",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8934"],
"port": 8934
}
]
}

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# Alt-Text Checker
Barrierefreiheits-Bookmarklet zur Prüfung von Alt-Texten auf beliebigen Webseiten.
## Installation
**Installationsseite mit Drag-&-Drop-Button:** https://saschab.codeberg.page/altchecker/
Falls die Seite nicht erreichbar ist oder Drag & Drop nicht funktioniert, geht es auch manuell:
1. Rechtsklick auf die Lesezeichenleiste deines Browsers → „Seite hinzufügen" o. ä.
2. Als Namen `Alt-Text Checker` eintragen.
3. Als Adresse den kompletten Inhalt von [`dist/bookmarklet.txt`](dist/bookmarklet.txt) einfügen.
4. Speichern.
Ist die Lesezeichenleiste nicht sichtbar? In Chrome/Edge/Firefox: `Strg+Umschalt+B` (Mac: `Cmd+Umschalt+B`).
## Benutzung
- Öffne eine beliebige Website.
- Klicke in der Lesezeichenleiste auf **„Alt-Text Checker"**.
- Jedes Bild bekommt sofort ein kleines Label mit Status.
- Erneuter Klick auf das Bookmarklet aktualisiert die Analyse (z. B. nach dem Scrollen zu neu geladenen Bildern).
## Was bedeuten die Farben?
- **Rot Fehler**: Kein `alt`-Attribut und kein ARIA-Ersatz. Screenreader können das Bild nicht beschreiben.
- **Orange Warnung**: Leeres `alt=""` (dekorativ) oder nur über ARIA benannt statt über `alt`.
- **Grün OK**: Alt-Text ist vorhanden und nicht leer.
- **Grau Ignoriert**: Das Bild wurde manuell von der Prüfung ausgeschlossen (z. B. bewusst dekorativ).
## Bild von Prüfung ausschließen
Klicke direkt auf das kleine Label neben einem Bild. Das Bild wird sofort als „ausgeschlossen" markiert. Erneuter Klick macht das rückgängig. Der Ausschluss wird im `localStorage` des Browsers pro Website-URL gespeichert und bleibt auch nach einem Neuladen der Seite erhalten.
## Datenschutz
Das Bookmarklet führt reines JavaScript im Browser aus. Es werden keine Daten an externe Server gesendet die einzige Speicherung erfolgt lokal im `localStorage` des Browsers.

49
build.js Normal file
View File

@ -0,0 +1,49 @@
// Baut aus src/alt-text-checker.js ein einzeiliges "javascript:"-Bookmarklet
// und schreibt es nach dist/bookmarklet.txt sowie in INSTALLIEREN.html.
const fs = require('fs');
const path = require('path');
const { minify } = require('terser');
const SRC = path.join(__dirname, 'src', 'alt-text-checker.js');
const DIST_DIR = path.join(__dirname, 'dist');
const HTML_FILE = path.join(__dirname, 'INSTALLIEREN.html');
async function build() {
const source = fs.readFileSync(SRC, 'utf8');
const result = await minify(source, {
compress: { unused: true, dead_code: true },
mangle: true
});
if (result.error) throw result.error;
const bookmarklet = 'javascript:' + encodeURIComponent(result.code);
fs.mkdirSync(DIST_DIR, { recursive: true });
fs.writeFileSync(path.join(DIST_DIR, 'alt-text-checker.min.js'), result.code, 'utf8');
fs.writeFileSync(path.join(DIST_DIR, 'bookmarklet.txt'), bookmarklet, 'utf8');
console.log(`Minified: ${source.length} -> ${result.code.length} bytes`);
console.log(`Bookmarklet: ${bookmarklet.length} bytes`);
if (fs.existsSync(HTML_FILE)) {
let html = fs.readFileSync(HTML_FILE, 'utf8');
const before = html;
html = html.replace(
/(<a id="bookmarklet-link"[^>]*href=")[^"]*(")/,
`$1${bookmarklet.replace(/&/g, '&amp;').replace(/"/g, '&quot;')}$2`
);
if (html !== before) {
fs.writeFileSync(HTML_FILE, html, 'utf8');
console.log('INSTALLIEREN.html aktualisiert.');
} else {
console.log('Hinweis: Platzhalter <a id="bookmarklet-link"> nicht gefunden, INSTALLIEREN.html nicht verändert.');
}
}
}
build().catch((err) => {
console.error(err);
process.exit(1);
});

1
dist/alt-text-checker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/bookmarklet.txt vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

133
package-lock.json generated Normal file
View File

@ -0,0 +1,133 @@
{
"name": "alttextchecker",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "alttextchecker",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"terser": "^5.49.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/terser": {
"version": "5.49.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz",
"integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
}
}
}

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "alttextchecker",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "node build.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"terser": "^5.49.0"
}
}

438
src/alt-text-checker.js Normal file
View File

@ -0,0 +1,438 @@
/**
* 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: 'HINWEIS'
};
// Nur bei diesen Status ergibt ein manueller Ausschluss Sinn (echte Probleme,
// die man als "geprüft und bewusst so gelassen" markieren will). "success"
// ist bereits in Ordnung, "info" (z.B. aria-hidden="true") ist bereits
// korrekt für Screenreader ausgeblendet beide brauchen keinen Ausschluss-Klick.
const EXCLUDABLE_KINDS = { error: true, warning: true };
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> oder <div role="img">)
// haben kein src. Fallback: umgebender Link, aria-label, oder als letzte
// Möglichkeit die class (z.B. bei reinen Icon-Fonts/CSS-Icons ohne jeden
// Text). Der instanceIdentifier()-Zähler unterscheidet danach weiterhin
// gleich aussehende Duplikate voneinander.
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}`;
if (img.className) return `role-img:class:${img.className}`;
return null;
}
// Mehrere Bilder können dieselbe imageIdentifier() haben (z.B. dasselbe Icon
// mehrfach auf der Seite). Ohne Zusatz würde ein Ausschluss dann alle gleich
// aussehenden Bilder treffen. Der Zähler hängt daher pro Lauf eine laufende
// Nummer an, sodass jedes Vorkommen einzeln aus-/eingeschlossen werden kann
// stabil über Neuladen, solange Reihenfolge und Anzahl der Duplikate gleich bleiben.
function instanceIdentifier(img, occurrenceCounts) {
const base = imageIdentifier(img);
if (base === null) return null;
const n = occurrenceCounts.get(base) || 0;
occurrenceCounts.set(base, n + 1);
return `${base}#${n}`;
}
function isExcluded(id, exclusions) {
return id !== null && exclusions.includes(id);
}
function toggleExclusion(id) {
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.
// Manche Frameworks (z.B. Salesforce LWC mit "synthetischem" Shadow DOM)
// hängen ein .shadowRoot an Elemente, dessen Inhalt in Wirklichkeit derselbe
// Light-DOM-Teilbaum ist statt einer echten Kapselung. Ohne Dedupe würde ein
// einzelnes Bild dann einmal pro verschachteltem Fake-Shadow-Level erneut
// gefunden und mit mehreren identischen Labels versehen. seenRoots/seenElements
// sorgen dafür, dass jeder Wurzelknoten und jedes Element nur einmal zählt.
function deepQueryAll(selector, root, callback, seenRoots, seenElements) {
root = root || document;
seenRoots = seenRoots || new Set();
seenElements = seenElements || new Set();
if (seenRoots.has(root)) return 0;
seenRoots.add(root);
let count = 0;
Array.from(root.querySelectorAll(selector)).forEach((el) => {
if (seenElements.has(el)) return;
seenElements.add(el);
callback(el);
count += 1;
});
Array.from(root.querySelectorAll('*')).forEach((el) => {
if (el.shadowRoot) count += deepQueryAll(selector, el.shadowRoot, callback, seenRoots, seenElements);
});
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
});
// Als Geschwisterknoten mit z-index eingefügte Labels bleiben "position: static"
// (z-index wirkt dort gar nicht) und können daher von jedem CSS-Overlay der
// besuchten Seite verdeckt und unklickbar gemacht werden (z.B. position:relative-
// Wrapper mit eigenem Overlay, wie bei Logo-Containern üblich). Labels werden
// deshalb wirklich absolut positioniert und direkt an document.body gehängt
// das verlässt jeden Stacking-Context der Seite und bleibt beim Scrollen
// trotzdem an der richtigen Stelle, da die Koordinaten dokumentrelativ sind.
if (opts.position === 'absolute') {
const rect = el.getBoundingClientRect();
style.position = 'absolute';
style.left = `${rect.left + window.scrollX}px`;
style.top = `${rect.top + window.scrollY}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') {
document.body.appendChild(wrapper);
} 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 occurrenceCounts = new Map();
const stats = { ok: 0, error: 0, warning: 0, info: 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;
const id = instanceIdentifier(img, occurrenceCounts);
if (isExcluded(id, exclusions)) {
stats.excluded += 1;
createLabel(img, 'excluded', ['Ausgeschlossen (manuell als dekorativ markiert)'], {
icon: '⦸',
position: 'absolute',
onClick: () => {
toggleExclusion(id);
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 isAriaHidden = img.getAttribute('aria-hidden') === 'true';
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 (isAriaHidden && !hasAccessibleName) {
// aria-hidden="true" entfernt das Element bereits vollständig aus dem
// Accessibility-Tree für Screenreader ist es so, als gäbe es kein
// Bild. Das ist bereits der gewünschte Zustand (kein Fehler, keine
// Warnung), daher nur ein Hinweis zur Information und nicht
// ausschließbar, da es nichts zu "beheben" gibt.
segments = [linkPrefix, { code: 'aria-hidden="true"' }, ...extras];
kind = 'info';
} 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;
const icon = kind === 'success' ? '✔' : kind === 'error' ? '✖' : kind === 'info' ? '' : '⚠️';
createLabel(img, kind, segments, {
icon,
position: 'absolute',
onClick: EXCLUDABLE_KINDS[kind]
? () => {
toggleExclusion(id);
run();
}
: undefined
});
});
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.info} Hinweis · ⦸ ${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();
})();

47
test/testseite.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Testseite Alt-Text Checker</title>
<style>
body { font-family: sans-serif; padding: 20px; }
img { width: 100px; height: 80px; background: #ddd; display: block; margin: 10px 0; }
figure { margin: 0; }
</style>
</head>
<body>
<h1>Testseite für Alt-Text Checker</h1>
<p>1. Bild ohne alt-Attribut (Fehler erwartet):</p>
<img src="https://via.placeholder.com/100x80/ff0000/ffffff?text=1">
<p>2. Bild mit leerem alt (Warnung, dekorativ):</p>
<img src="https://via.placeholder.com/100x80/ffa500/ffffff?text=2" alt="">
<p>3. Bild mit Alt-Text (OK):</p>
<img src="https://via.placeholder.com/100x80/00ff00/000000?text=3" alt="Ein grünes Testbild mit der Zahl drei">
<p>4. Bild mit aria-label statt alt (Warnung):</p>
<img src="https://via.placeholder.com/100x80/0000ff/ffffff?text=4" aria-label="Blaues Testbild via ARIA">
<p>5. Bild in einem Link:</p>
<a href="#test"><img src="https://via.placeholder.com/100x80/purple/ffffff?text=5"></a>
<p>6. Bild mit aria-describedby:</p>
<img src="https://via.placeholder.com/100x80/808080/ffffff?text=6" aria-describedby="desc6">
<p id="desc6">Diese Beschreibung wird per aria-describedby referenziert.</p>
<p>7. Falsche Verwendung: alt auf einem Link statt auf dem Bild:</p>
<a href="#test2" alt="Sollte hier nicht stehen">Link mit falschem alt-Attribut</a>
<p>8. Bild mit potenziell gefährlichem alt-Text (XSS-Test):</p>
<img src="https://via.placeholder.com/100x80/000000/ffffff?text=8" alt="&lt;img src=x onerror=alert('XSS')&gt;">
<p>9a. Dasselbe Bild dreimal ohne alt (Duplikat-Test nur dieses eine ausschließen):</p>
<img src="https://via.placeholder.com/100x80/999999/ffffff?text=9">
<p>9b. Zweites Vorkommen desselben Bildes:</p>
<img src="https://via.placeholder.com/100x80/999999/ffffff?text=9">
<p>9c. Drittes Vorkommen desselben Bildes:</p>
<img src="https://via.placeholder.com/100x80/999999/ffffff?text=9">
</body>
</html>