Initial commit

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Sascha 2026-07-13 10:40:23 +02:00
commit ba3d2c88e8
8 changed files with 599 additions and 0 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
}
]
}

111
INSTALLIEREN.html Normal file

File diff suppressed because one or more lines are too long

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

14
package.json Normal file
View File

@ -0,0 +1,14 @@
{
"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"
}

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

@ -0,0 +1,372 @@
/**
* 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
return img.currentSrc || img.src || 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();
})();

40
test/testseite.html Normal file
View File

@ -0,0 +1,40 @@
<!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;">
</body>
</html>