Initial commit
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
ba3d2c88e8
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "static",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8934"],
|
||||
"port": 8934
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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, '&').replace(/"/g, '"')}$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);
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -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="<img src=x onerror=alert('XSS')>">
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue