commit ba3d2c88e893850ef29be8c0a89af583169b5d16 Author: Sascha Date: Mon Jul 13 10:40:23 2026 +0200 Initial commit Co-Authored-By: Claude Sonnet 5 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..886e242 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "static", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "http.server", "8934"], + "port": 8934 + } + ] +} diff --git a/INSTALLIEREN.html b/INSTALLIEREN.html new file mode 100644 index 0000000..49109a0 --- /dev/null +++ b/INSTALLIEREN.html @@ -0,0 +1,111 @@ + + + + + Alt-Text Checker – Installationsanleitung + + + + +

Alt-Text Checker

+

Barrierefreiheits-Bookmarklet – Installationsanleitung

+ +

1. Bookmarklet installieren

+

Ziehe den folgenden Link per Drag & Drop in deine Lesezeichenleiste (Favoritenleiste):

+ +

+ Alt-Text Checker +

+ +
+

Ist die Lesezeichenleiste nicht sichtbar? In Chrome/Edge/Firefox: Strg+Umschalt+B (Mac: Cmd+Umschalt+B).

+
+ +
+

Falls Drag & Drop nicht funktioniert: Lesezeichen manuell anlegen (Rechtsklick auf Lesezeichenleiste → „Seite hinzufügen" o.ä.), als Namen „Alt-Text Checker" und als Adresse den Link-Code aus dist/bookmarklet.txt eintragen.

+
+ +

2. Bookmarklet benutzen

+ + +

Was bedeuten die Farben?

+ + +

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 deines 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.

+ + + diff --git a/build.js b/build.js new file mode 100644 index 0000000..00be7c3 --- /dev/null +++ b/build.js @@ -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( + /(]*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 nicht gefunden, INSTALLIEREN.html nicht verändert.'); + } + } +} + +build().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/dist/alt-text-checker.min.js b/dist/alt-text-checker.min.js new file mode 100644 index 0000000..46776ff --- /dev/null +++ b/dist/alt-text-checker.min.js @@ -0,0 +1 @@ +!function(){"use strict";const e="atc",t={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"}},n={success:"OK",error:"FEHLER",warning:"WARNUNG",excluded:"AUSGESCHLOSSEN",info:"INFO"},o={"font-family":"sans-serif","font-size":"small",padding:"5px 8px",outline:"2px dotted #000","outline-offset":"-3px",border:"0",cursor:"default","z-index":"2147483647"};function l(){return"alt-text-checker:excluded:"+location.href}function r(){try{const e=localStorage.getItem(l());return e?JSON.parse(e):[]}catch(e){return[]}}function i(e){return e.currentSrc||e.src||null}function a(e){const t=i(e);if(null===t)return;const n=r(),o=n.indexOf(t);-1===o?n.push(t):n.splice(o,1),function(e){try{localStorage.setItem(l(),JSON.stringify(e))}catch(e){}}(n)}function c(e,t,n){let o=0;return Array.from((t||document).querySelectorAll(e)).forEach(e=>{n(e),o+=1}),Array.from((t||document).querySelectorAll("*")).forEach(t=>{t.shadowRoot&&(o+=c(e,t.shadowRoot,n))}),o}function s(e){return!(!e.parentNode||"A"!==e.parentNode.tagName&&!s(e.parentNode))}function u(l,r,i,a){const c=t[r],s=!!(a=a||{}).global,u=document.createElement(s?"label":"button");u.className=`${e} ${e}-${r}`.trim();const d=Object.assign({},o,{"background-color":c.bg,"outline-color":c.outline,color:c.text});var f;"absolute"===a.position&&l.offsetParent&&l.offsetParent!==document.body&&(d.left=`${l.offsetLeft}px`,d.top=`${l.offsetTop}px`),a.extraStyle&&Object.assign(d,a.extraStyle),u.setAttribute("style",(f=d,Object.keys(f).map(e=>`${e}:${f[e]}`).join(";")));const g=a.icon||"";if(s)u.textContent=g?`${g} ${i}`:i;else{u.appendChild(document.createTextNode(g));const t=document.createElement("span");t.className=`${e}-badge`,t.textContent=n[r]||"",t.style.marginLeft="4px",t.style.fontWeight="700",u.appendChild(t);const o=document.createElement("span");if(o.className=`${e}-details`,o.appendChild(function(e){const t=document.createDocumentFragment();return e.forEach(e=>{if("string"==typeof e)t.appendChild(document.createTextNode(e));else if(void 0!==e.code){const n=document.createElement("code");n.textContent=e.code,t.appendChild(n)}else if(void 0!==e.strike){const n=document.createElement("s");n.textContent=e.strike,t.appendChild(n)}}),t}(i)),o.style.color="inherit",o.style.marginLeft="4px",u.appendChild(o),!a.alwaysOpen){o.style.display="none";const t=()=>{0===document.querySelectorAll(`.${e}-toggle:checked`).length&&(o.style.display="inline")},n=()=>{0===document.querySelectorAll(`.${e}-toggle:checked`).length&&(o.style.display="none")};u.addEventListener("focus",t),u.addEventListener("mouseover",t),u.addEventListener("blur",n),u.addEventListener("mouseout",n)}}if(a.onClick&&u.addEventListener("click",e=>{e.preventDefault(),e.stopPropagation(),a.onClick()}),s&&a.withToggle){u.appendChild(document.createTextNode(" — alle Details anzeigen"));const t=document.createElement("input");t.type="checkbox",t.className=`${e}-toggle`,t.style.margin="0 0 0 4px",t.style.verticalAlign="baseline",t.addEventListener("change",function(){document.querySelectorAll(`.${e}-details`).forEach(e=>{e.style.display=this.checked?"inline":"none"})}),u.appendChild(t)}return s&&(document.querySelectorAll(`.${e}-global`).forEach(e=>e.remove()),u.className+=` ${e}-global`),"absolute"===a.position&&l.parentNode?l.parentNode.insertBefore(u,l.nextSibling):(a.insertInto||l.parentNode).appendChild(u),u}function d(e,t){const n=t.getAttribute(e);if(!n)return null;const o=n.split(" ").map(e=>e.trim()).filter(Boolean),l=[];return o.forEach(n=>{const o=document.getElementById(n);if(!o)return;u(o,"warning",[`Referenziert von [${e}="`,{code:t.id||"…"},'"]'],{icon:"⚠️",position:"absolute"});const r=o.textContent.trim();r&&l.push(r)}),l.length?l.join(" "):null}!function t(){c(`.${e}`,document,e=>e.remove());const n=r(),o={ok:0,error:0,warning:0,excluded:0,total:0};c("a[alt], button[alt], label[alt]",document,e=>{u(e,"error",[`<${e.tagName.toLowerCase()}> mit ungültigem `,{code:`alt="${e.getAttribute("alt")}"`}],{icon:"✖",position:"absolute"})}),c('img, [role="img"]',document,e=>{if(o.total+=1,function(e,t){const n=i(e);return null!==n&&t.includes(n)}(e,n))return o.excluded+=1,void u(e,"excluded",["Ausgeschlossen (manuell als dekorativ markiert)"],{icon:"⦸",position:"absolute",onClick:()=>{a(e),t()}});const l=e.hasAttribute("alt"),r=l?e.getAttribute("alt"):null,c=e.hasAttribute("aria-label"),f=d("aria-describedby",e),g=d("aria-labelledby",e),p=e.hasAttribute("title"),m=e.hasAttribute("longdesc"),b=c||null!==f||null!==g,h=s(e)?"Bild in Link: ":"",y=[];let x,$;if(p&&y.push(" ",{code:`title="${e.getAttribute("title")}"`}),m&&y.push(" ",{code:`longdesc="${e.getAttribute("longdesc")}"`}),l){const e=0===r.trim().length;x=[h,{code:`alt="${r}"`},...y],$=e?"warning":"success"}else if(b){x=[p?"":h,{strike:"alt"}," fehlt, aber ARIA-Name vorhanden: ",{code:c?e.getAttribute("aria-label"):g||f},...y],$="warning"}else x=[h,{strike:"alt"}," fehlt",...y],$="error";o["success"===$?"ok":$]+=1,u(e,$,x,{icon:"success"===$?"✔":"error"===$?"✖":"⚠️",position:"absolute",onClick:()=>{a(e),t()}})});const l=o.error>0?"error":o.warning>0?"warning":"success",f=o.total?`Bilder: ${o.total} · ✔ ${o.ok} OK · ✖ ${o.error} Fehler · ⚠️ ${o.warning} Warnung · ⦸ ${o.excluded} ausgeschlossen`:"Keine Bilder auf dieser Seite gefunden.";u(document.body,o.total?l:"info",f,{global:!0,withToggle:!!o.total,extraStyle:{position:"fixed",top:"0",left:"0"}})}()}(); \ No newline at end of file diff --git a/dist/bookmarklet.txt b/dist/bookmarklet.txt new file mode 100644 index 0000000..b5bf4af --- /dev/null +++ b/dist/bookmarklet.txt @@ -0,0 +1 @@ +javascript:!function()%7B%22use%20strict%22%3Bconst%20e%3D%22atc%22%2Ct%3D%7Bsuccess%3A%7Bbg%3A%22lime%22%2Coutline%3A%22%23000%22%2Ctext%3A%22%23000%22%7D%2Cerror%3A%7Bbg%3A%22%23EB0000%22%2Coutline%3A%22%23fff%22%2Ctext%3A%22%23fff%22%7D%2Cwarning%3A%7Bbg%3A%22orange%22%2Coutline%3A%22%23000%22%2Ctext%3A%22%23000%22%7D%2Cexcluded%3A%7Bbg%3A%22%23e0e0e0%22%2Coutline%3A%22%23666%22%2Ctext%3A%22%23333%22%7D%2Cinfo%3A%7Bbg%3A%22yellow%22%2Coutline%3A%22%23000%22%2Ctext%3A%22%23000%22%7D%7D%2Cn%3D%7Bsuccess%3A%22OK%22%2Cerror%3A%22FEHLER%22%2Cwarning%3A%22WARNUNG%22%2Cexcluded%3A%22AUSGESCHLOSSEN%22%2Cinfo%3A%22INFO%22%7D%2Co%3D%7B%22font-family%22%3A%22sans-serif%22%2C%22font-size%22%3A%22small%22%2Cpadding%3A%225px%208px%22%2Coutline%3A%222px%20dotted%20%23000%22%2C%22outline-offset%22%3A%22-3px%22%2Cborder%3A%220%22%2Ccursor%3A%22default%22%2C%22z-index%22%3A%222147483647%22%7D%3Bfunction%20l()%7Breturn%22alt-text-checker%3Aexcluded%3A%22%2Blocation.href%7Dfunction%20r()%7Btry%7Bconst%20e%3DlocalStorage.getItem(l())%3Breturn%20e%3FJSON.parse(e)%3A%5B%5D%7Dcatch(e)%7Breturn%5B%5D%7D%7Dfunction%20i(e)%7Breturn%20e.currentSrc%7C%7Ce.src%7C%7Cnull%7Dfunction%20a(e)%7Bconst%20t%3Di(e)%3Bif(null%3D%3D%3Dt)return%3Bconst%20n%3Dr()%2Co%3Dn.indexOf(t)%3B-1%3D%3D%3Do%3Fn.push(t)%3An.splice(o%2C1)%2Cfunction(e)%7Btry%7BlocalStorage.setItem(l()%2CJSON.stringify(e))%7Dcatch(e)%7B%7D%7D(n)%7Dfunction%20c(e%2Ct%2Cn)%7Blet%20o%3D0%3Breturn%20Array.from((t%7C%7Cdocument).querySelectorAll(e)).forEach(e%3D%3E%7Bn(e)%2Co%2B%3D1%7D)%2CArray.from((t%7C%7Cdocument).querySelectorAll(%22*%22)).forEach(t%3D%3E%7Bt.shadowRoot%26%26(o%2B%3Dc(e%2Ct.shadowRoot%2Cn))%7D)%2Co%7Dfunction%20s(e)%7Breturn!(!e.parentNode%7C%7C%22A%22!%3D%3De.parentNode.tagName%26%26!s(e.parentNode))%7Dfunction%20u(l%2Cr%2Ci%2Ca)%7Bconst%20c%3Dt%5Br%5D%2Cs%3D!!(a%3Da%7C%7C%7B%7D).global%2Cu%3Ddocument.createElement(s%3F%22label%22%3A%22button%22)%3Bu.className%3D%60%24%7Be%7D%20%24%7Be%7D-%24%7Br%7D%60.trim()%3Bconst%20d%3DObject.assign(%7B%7D%2Co%2C%7B%22background-color%22%3Ac.bg%2C%22outline-color%22%3Ac.outline%2Ccolor%3Ac.text%7D)%3Bvar%20f%3B%22absolute%22%3D%3D%3Da.position%26%26l.offsetParent%26%26l.offsetParent!%3D%3Ddocument.body%26%26(d.left%3D%60%24%7Bl.offsetLeft%7Dpx%60%2Cd.top%3D%60%24%7Bl.offsetTop%7Dpx%60)%2Ca.extraStyle%26%26Object.assign(d%2Ca.extraStyle)%2Cu.setAttribute(%22style%22%2C(f%3Dd%2CObject.keys(f).map(e%3D%3E%60%24%7Be%7D%3A%24%7Bf%5Be%5D%7D%60).join(%22%3B%22)))%3Bconst%20g%3Da.icon%7C%7C%22%22%3Bif(s)u.textContent%3Dg%3F%60%24%7Bg%7D%20%24%7Bi%7D%60%3Ai%3Belse%7Bu.appendChild(document.createTextNode(g))%3Bconst%20t%3Ddocument.createElement(%22span%22)%3Bt.className%3D%60%24%7Be%7D-badge%60%2Ct.textContent%3Dn%5Br%5D%7C%7C%22%22%2Ct.style.marginLeft%3D%224px%22%2Ct.style.fontWeight%3D%22700%22%2Cu.appendChild(t)%3Bconst%20o%3Ddocument.createElement(%22span%22)%3Bif(o.className%3D%60%24%7Be%7D-details%60%2Co.appendChild(function(e)%7Bconst%20t%3Ddocument.createDocumentFragment()%3Breturn%20e.forEach(e%3D%3E%7Bif(%22string%22%3D%3Dtypeof%20e)t.appendChild(document.createTextNode(e))%3Belse%20if(void%200!%3D%3De.code)%7Bconst%20n%3Ddocument.createElement(%22code%22)%3Bn.textContent%3De.code%2Ct.appendChild(n)%7Delse%20if(void%200!%3D%3De.strike)%7Bconst%20n%3Ddocument.createElement(%22s%22)%3Bn.textContent%3De.strike%2Ct.appendChild(n)%7D%7D)%2Ct%7D(i))%2Co.style.color%3D%22inherit%22%2Co.style.marginLeft%3D%224px%22%2Cu.appendChild(o)%2C!a.alwaysOpen)%7Bo.style.display%3D%22none%22%3Bconst%20t%3D()%3D%3E%7B0%3D%3D%3Ddocument.querySelectorAll(%60.%24%7Be%7D-toggle%3Achecked%60).length%26%26(o.style.display%3D%22inline%22)%7D%2Cn%3D()%3D%3E%7B0%3D%3D%3Ddocument.querySelectorAll(%60.%24%7Be%7D-toggle%3Achecked%60).length%26%26(o.style.display%3D%22none%22)%7D%3Bu.addEventListener(%22focus%22%2Ct)%2Cu.addEventListener(%22mouseover%22%2Ct)%2Cu.addEventListener(%22blur%22%2Cn)%2Cu.addEventListener(%22mouseout%22%2Cn)%7D%7Dif(a.onClick%26%26u.addEventListener(%22click%22%2Ce%3D%3E%7Be.preventDefault()%2Ce.stopPropagation()%2Ca.onClick()%7D)%2Cs%26%26a.withToggle)%7Bu.appendChild(document.createTextNode(%22%20%E2%80%94%20alle%20Details%20anzeigen%22))%3Bconst%20t%3Ddocument.createElement(%22input%22)%3Bt.type%3D%22checkbox%22%2Ct.className%3D%60%24%7Be%7D-toggle%60%2Ct.style.margin%3D%220%200%200%204px%22%2Ct.style.verticalAlign%3D%22baseline%22%2Ct.addEventListener(%22change%22%2Cfunction()%7Bdocument.querySelectorAll(%60.%24%7Be%7D-details%60).forEach(e%3D%3E%7Be.style.display%3Dthis.checked%3F%22inline%22%3A%22none%22%7D)%7D)%2Cu.appendChild(t)%7Dreturn%20s%26%26(document.querySelectorAll(%60.%24%7Be%7D-global%60).forEach(e%3D%3Ee.remove())%2Cu.className%2B%3D%60%20%24%7Be%7D-global%60)%2C%22absolute%22%3D%3D%3Da.position%26%26l.parentNode%3Fl.parentNode.insertBefore(u%2Cl.nextSibling)%3A(a.insertInto%7C%7Cl.parentNode).appendChild(u)%2Cu%7Dfunction%20d(e%2Ct)%7Bconst%20n%3Dt.getAttribute(e)%3Bif(!n)return%20null%3Bconst%20o%3Dn.split(%22%20%22).map(e%3D%3Ee.trim()).filter(Boolean)%2Cl%3D%5B%5D%3Breturn%20o.forEach(n%3D%3E%7Bconst%20o%3Ddocument.getElementById(n)%3Bif(!o)return%3Bu(o%2C%22warning%22%2C%5B%60Referenziert%20von%20%5B%24%7Be%7D%3D%22%60%2C%7Bcode%3At.id%7C%7C%22%E2%80%A6%22%7D%2C'%22%5D'%5D%2C%7Bicon%3A%22%E2%9A%A0%EF%B8%8F%22%2Cposition%3A%22absolute%22%7D)%3Bconst%20r%3Do.textContent.trim()%3Br%26%26l.push(r)%7D)%2Cl.length%3Fl.join(%22%20%22)%3Anull%7D!function%20t()%7Bc(%60.%24%7Be%7D%60%2Cdocument%2Ce%3D%3Ee.remove())%3Bconst%20n%3Dr()%2Co%3D%7Bok%3A0%2Cerror%3A0%2Cwarning%3A0%2Cexcluded%3A0%2Ctotal%3A0%7D%3Bc(%22a%5Balt%5D%2C%20button%5Balt%5D%2C%20label%5Balt%5D%22%2Cdocument%2Ce%3D%3E%7Bu(e%2C%22error%22%2C%5B%60%3C%24%7Be.tagName.toLowerCase()%7D%3E%20mit%20ung%C3%BCltigem%20%60%2C%7Bcode%3A%60alt%3D%22%24%7Be.getAttribute(%22alt%22)%7D%22%60%7D%5D%2C%7Bicon%3A%22%E2%9C%96%22%2Cposition%3A%22absolute%22%7D)%7D)%2Cc('img%2C%20%5Brole%3D%22img%22%5D'%2Cdocument%2Ce%3D%3E%7Bif(o.total%2B%3D1%2Cfunction(e%2Ct)%7Bconst%20n%3Di(e)%3Breturn%20null!%3D%3Dn%26%26t.includes(n)%7D(e%2Cn))return%20o.excluded%2B%3D1%2Cvoid%20u(e%2C%22excluded%22%2C%5B%22Ausgeschlossen%20(manuell%20als%20dekorativ%20markiert)%22%5D%2C%7Bicon%3A%22%E2%A6%B8%22%2Cposition%3A%22absolute%22%2ConClick%3A()%3D%3E%7Ba(e)%2Ct()%7D%7D)%3Bconst%20l%3De.hasAttribute(%22alt%22)%2Cr%3Dl%3Fe.getAttribute(%22alt%22)%3Anull%2Cc%3De.hasAttribute(%22aria-label%22)%2Cf%3Dd(%22aria-describedby%22%2Ce)%2Cg%3Dd(%22aria-labelledby%22%2Ce)%2Cp%3De.hasAttribute(%22title%22)%2Cm%3De.hasAttribute(%22longdesc%22)%2Cb%3Dc%7C%7Cnull!%3D%3Df%7C%7Cnull!%3D%3Dg%2Ch%3Ds(e)%3F%22Bild%20in%20Link%3A%20%22%3A%22%22%2Cy%3D%5B%5D%3Blet%20x%2C%24%3Bif(p%26%26y.push(%22%20%22%2C%7Bcode%3A%60title%3D%22%24%7Be.getAttribute(%22title%22)%7D%22%60%7D)%2Cm%26%26y.push(%22%20%22%2C%7Bcode%3A%60longdesc%3D%22%24%7Be.getAttribute(%22longdesc%22)%7D%22%60%7D)%2Cl)%7Bconst%20e%3D0%3D%3D%3Dr.trim().length%3Bx%3D%5Bh%2C%7Bcode%3A%60alt%3D%22%24%7Br%7D%22%60%7D%2C...y%5D%2C%24%3De%3F%22warning%22%3A%22success%22%7Delse%20if(b)%7Bx%3D%5Bp%3F%22%22%3Ah%2C%7Bstrike%3A%22alt%22%7D%2C%22%20fehlt%2C%20aber%20ARIA-Name%20vorhanden%3A%20%22%2C%7Bcode%3Ac%3Fe.getAttribute(%22aria-label%22)%3Ag%7C%7Cf%7D%2C...y%5D%2C%24%3D%22warning%22%7Delse%20x%3D%5Bh%2C%7Bstrike%3A%22alt%22%7D%2C%22%20fehlt%22%2C...y%5D%2C%24%3D%22error%22%3Bo%5B%22success%22%3D%3D%3D%24%3F%22ok%22%3A%24%5D%2B%3D1%2Cu(e%2C%24%2Cx%2C%7Bicon%3A%22success%22%3D%3D%3D%24%3F%22%E2%9C%94%22%3A%22error%22%3D%3D%3D%24%3F%22%E2%9C%96%22%3A%22%E2%9A%A0%EF%B8%8F%22%2Cposition%3A%22absolute%22%2ConClick%3A()%3D%3E%7Ba(e)%2Ct()%7D%7D)%7D)%3Bconst%20l%3Do.error%3E0%3F%22error%22%3Ao.warning%3E0%3F%22warning%22%3A%22success%22%2Cf%3Do.total%3F%60Bilder%3A%20%24%7Bo.total%7D%20%C2%B7%20%E2%9C%94%20%24%7Bo.ok%7D%20OK%20%C2%B7%20%E2%9C%96%20%24%7Bo.error%7D%20Fehler%20%C2%B7%20%E2%9A%A0%EF%B8%8F%20%24%7Bo.warning%7D%20Warnung%20%C2%B7%20%E2%A6%B8%20%24%7Bo.excluded%7D%20ausgeschlossen%60%3A%22Keine%20Bilder%20auf%20dieser%20Seite%20gefunden.%22%3Bu(document.body%2Co.total%3Fl%3A%22info%22%2Cf%2C%7Bglobal%3A!0%2CwithToggle%3A!!o.total%2CextraStyle%3A%7Bposition%3A%22fixed%22%2Ctop%3A%220%22%2Cleft%3A%220%22%7D%7D)%7D()%7D()%3B \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..8435ce8 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/src/alt-text-checker.js b/src/alt-text-checker.js new file mode 100644 index 0000000..a1df1b0 --- /dev/null +++ b/src/alt-text-checker.js @@ -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 / 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--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(); +})(); diff --git a/test/testseite.html b/test/testseite.html new file mode 100644 index 0000000..98eb07c --- /dev/null +++ b/test/testseite.html @@ -0,0 +1,40 @@ + + + + + Testseite – Alt-Text Checker + + + +

Testseite für Alt-Text Checker

+ +

1. Bild ohne alt-Attribut (Fehler erwartet):

+ + +

2. Bild mit leerem alt (Warnung, dekorativ):

+ + +

3. Bild mit Alt-Text (OK):

+ Ein grünes Testbild mit der Zahl drei + +

4. Bild mit aria-label statt alt (Warnung):

+ + +

5. Bild in einem Link:

+
+ +

6. Bild mit aria-describedby:

+ +

Diese Beschreibung wird per aria-describedby referenziert.

+ +

7. Falsche Verwendung: alt auf einem Link statt auf dem Bild:

+ Link mit falschem alt-Attribut + +

8. Bild mit potenziell gefährlichem alt-Text (XSS-Test):

+ <img src=x onerror=alert('XSS')> + +