Compare commits
No commits in common. "pages" and "main" have entirely different histories.
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "static",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8934"],
|
||||
"port": 8934
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
111
index.html
111
index.html
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -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="<img src=x onerror=alert('XSS')>">
|
||||
|
||||
<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>
|
||||
Loading…
Reference in New Issue