Refactor lint.yml comments and accessibility check #8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Lint & Quality Check | |
| on: | |
| push: | |
| branches: [main] | |
| jobs: | |
| lint: | |
| name: HTML · CSS · Accessibility · Links | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| # 1. HTML VALIDITY | |
| - name: Validate HTML (W3C) | |
| uses: Cyb3r-Jak3/html5validator-action@v7.2.0 | |
| with: | |
| root: . | |
| css: false | |
| format: text | |
| # 2. CSS VALIDITY | |
| - name: Set up Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| - name: Install stylelint | |
| run: npm install -g stylelint stylelint-config-standard | |
| - name: Write stylelint config | |
| run: | | |
| cat > .stylelintrc.json << 'EOF' | |
| { | |
| "extends": "stylelint-config-standard", | |
| "rules": { | |
| "no-descending-specificity": null, | |
| "alpha-value-notation": null, | |
| "color-function-notation": null, | |
| "color-function-alias-notation": null, | |
| "color-hex-length": null, | |
| "declaration-block-single-line-max-declarations": null, | |
| "keyframes-name-pattern": null, | |
| "media-feature-range-notation": null | |
| } | |
| } | |
| EOF | |
| - name: Extract inline CSS and lint | |
| run: | | |
| node - << 'EOF' | |
| const fs = require('fs'); | |
| const html = fs.readFileSync('index.html', 'utf8'); | |
| const match = html.match(/<style[^>]*>([\s\S]*?)<\/style>/i); | |
| if (match) { | |
| fs.writeFileSync('_extracted.css', match[1]); | |
| console.log('CSS extracted successfully'); | |
| } else { | |
| console.log('No inline <style> block found'); | |
| fs.writeFileSync('_extracted.css', ''); | |
| } | |
| EOF | |
| stylelint _extracted.css --allow-empty-input | |
| rm -f _extracted.css | |
| # 3. ACCESSIBILITY (pure Node, no browser needed) | |
| - name: Run accessibility check | |
| run: | | |
| node - << 'EOF' | |
| const fs = require('fs'); | |
| const html = fs.readFileSync('index.html', 'utf8'); | |
| let errors = []; | |
| let passes = []; | |
| // 1. lang attribute on <html> | |
| if (/<html[^>]+lang=["'][a-z]+["']/i.test(html)) { | |
| passes.push('✅ <html> has lang attribute'); | |
| } else { | |
| errors.push('❌ <html> missing lang attribute (WCAG 3.1.1)'); | |
| } | |
| // 2. <title> exists and is non-empty | |
| const titleMatch = html.match(/<title>([^<]+)<\/title>/i); | |
| if (titleMatch && titleMatch[1].trim()) { | |
| passes.push('✅ <title> present: "' + titleMatch[1].trim() + '"'); | |
| } else { | |
| errors.push('❌ Missing or empty <title> (WCAG 2.4.2)'); | |
| } | |
| // 3. All <a> tags have non-empty text or aria-label | |
| const anchors = [...html.matchAll(/<a\s[^>]*>([\s\S]*?)<\/a>/gi)]; | |
| anchors.forEach(match => { | |
| const tag = match[0]; | |
| const inner = match[1].replace(/<[^>]+>/g, '').trim(); | |
| const hasAriaLabel = /aria-label=["'][^"']+["']/i.test(tag); | |
| if (!inner && !hasAriaLabel) { | |
| errors.push('❌ <a> with no text or aria-label (WCAG 2.4.4): ' + tag.slice(0, 80)); | |
| } | |
| }); | |
| if (anchors.length > 0 && errors.filter(e => e.includes('2.4.4')).length === 0) { | |
| passes.push('✅ All ' + anchors.length + ' links have accessible text'); | |
| } | |
| // 4. No duplicate IDs | |
| const ids = [...html.matchAll(/\sid=["']([^"']+)["']/gi)].map(m => m[1]); | |
| const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); | |
| if (dupes.length > 0) { | |
| errors.push('❌ Duplicate IDs found: ' + dupes.join(', ') + ' (WCAG 4.1.1)'); | |
| } else { | |
| passes.push('✅ No duplicate IDs'); | |
| } | |
| // 5. All <img> tags have alt attributes | |
| const imgs = [...html.matchAll(/<img\s[^>]*>/gi)]; | |
| imgs.forEach(match => { | |
| if (!/alt=["'][^"']*["']/i.test(match[0])) { | |
| errors.push('❌ <img> missing alt attribute (WCAG 1.1.1): ' + match[0].slice(0, 80)); | |
| } | |
| }); | |
| if (imgs.length === 0) passes.push('✅ No images without alt (no images present)'); | |
| // 6. target="_blank" links have rel="noopener" | |
| const blankLinks = [...html.matchAll(/<a\s[^>]*target=["']_blank["'][^>]*>/gi)]; | |
| blankLinks.forEach(match => { | |
| if (!/rel=["'][^"']*(noopener|noreferrer)[^"']*["']/i.test(match[0])) { | |
| errors.push('❌ target="_blank" link missing rel="noopener": ' + match[0].slice(0, 80)); | |
| } | |
| }); | |
| if (blankLinks.length > 0 && errors.filter(e => e.includes('noopener')).length === 0) { | |
| passes.push('✅ All ' + blankLinks.length + ' external links have rel="noopener"'); | |
| } | |
| // Report | |
| console.log('\n── Accessibility Check ──────────────────\n'); | |
| passes.forEach(p => console.log(p)); | |
| if (errors.length > 0) { | |
| console.log(''); | |
| errors.forEach(e => console.error(e)); | |
| console.log('\n❌ ' + errors.length + ' violation(s) found'); | |
| process.exit(1); | |
| } else { | |
| console.log('\n✅ All checks passed!'); | |
| process.exit(0); | |
| } | |
| EOF | |
| # 4. BROKEN LINKS | |
| - name: Check broken links (lychee) | |
| uses: lycheeverse/lychee-action@v1 | |
| with: | |
| args: > | |
| --verbose | |
| --no-progress | |
| --exclude "mailto:" | |
| --exclude "localhost" | |
| --timeout 20 | |
| ./index.html | |
| fail: true |