readback
Lists what a crawler or screen reader gets from any part of a page, in reading order. Flags images without alt text, links and buttons without names, and inputs without labels.
- 1.4 KB gzipped
- 0 dependencies
- 1 file
- inspired by crwl
Try it
The night shift
We answer every call after 9 pm with an agent that knows the menu, the hours and the specials.
Your regulars hear a voice they know. New callers get a table.
Install
Download the file into your project and import it. There is no package and no build step, so the code is yours to change.
curl -O /proof/r/readback.jsUsage
import { read, mark } from './readback.js'
const lines = read(document.querySelector('main'))
const gaps = lines.filter(l => l.gap)
console.log(`${gaps.length} things a machine cannot read`)
mark(document.querySelector('main')) // tags each element in placeAPI
read(root)
| option | type | default | what it does |
|---|---|---|---|
| root | Element | required | The part of the page to read. Hidden and aria-hidden parts are skipped. |
Returns
| [{ tag, text, gap, el }] | One line per heading, paragraph, image, link, button and form field, in reading order. |
Also exported
| mark(root) | Runs read, then sets data-rb on each element and adds the rb-gap class to every gap. Safe to run again. Each returned line also has node, the element that carries the mark. |
Notes
- A gap is an image with no alt text, a link or button with no name, or a form field (input, textarea or select) with no label. Submit and button inputs are named by their value.
- mark wraps each img and form field in a span with the class rb-wrap, because CSS cannot draw a label on those elements. Everything else only gets attributes. How a gap looks is up to your CSS.
- Links and buttons inside headings and paragraphs are read too. An image inside a link or button gives it a name through its alt text.
Source
The whole library. Read it, copy it, change it.
// readback.js from proof, by Alok Ranjan (https://alokranjan.me). MIT license.
const text = n => (n.textContent || '').replace(/\s+/g, ' ').trim()
// a label can wrap its own control, whose option or field text is not part of the name
const labelText = l => { const c = l.cloneNode(true); c.querySelectorAll('select, textarea, input').forEach(x => x.remove()); return text(c) }
// an image inside a link or button names it, the way screen readers announce it
const alts = el => [...el.querySelectorAll('img[alt]')].filter(i => !i.closest('[aria-hidden="true"]')).map(i => i.alt).join(' ').trim()
const name = el => el.getAttribute('aria-label') || text(el) || alts(el) || el.getAttribute('title') || ''
function judge(n) {
const tag = n.tagName.toLowerCase()
if (/^h[1-6]$/.test(tag) || tag === 'p') return { tag, text: text(n), block: true }
if (tag === 'img') return n.alt ? { tag, text: `"${n.alt}"` } : { tag, text: 'no alt text', gap: true }
if (tag === 'a') return name(n) ? { tag, text: `${name(n)} → ${n.getAttribute('href')}` } : { tag, text: `no link text → ${n.getAttribute('href')}`, gap: true }
if (tag === 'button') return name(n) ? { tag, text: name(n) } : { tag, text: 'no label', gap: true }
// button-like inputs are named by their value (an image input by its alt), like a button by its text
if (tag === 'input' && ['submit', 'reset', 'button', 'image'].includes(n.type)) {
const label = n.getAttribute('aria-label') || (n.type === 'image' ? n.alt : n.value) || { submit: 'Submit', reset: 'Reset' }[n.type]
return label ? { tag, text: `${n.type}: ${label}` } : { tag, text: `${n.type}: no label`, gap: true }
}
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
const kind = tag === 'input' ? n.type : tag
const label = n.labels?.[0] ? labelText(n.labels[0]) : n.getAttribute('aria-label')
return label ? { tag, text: `${kind}: ${label}` } : { tag, text: `${kind}: no label`, gap: true }
}
return null
}
// what a crawler or screen reader gets from a subtree, in reading order, with the element each line came from
export function read(root) {
const lines = []
const walk = el => {
for (const n of el.children) {
if (n.closest('[aria-hidden="true"]') || getComputedStyle(n).display === 'none') continue
const line = judge(n)
if (line) lines.push({ tag: line.tag, text: line.text, gap: line.gap, el: n })
// text blocks can hold links and buttons of their own, so their children are read too
if (!line || line.block) walk(n)
}
}
walk(root)
return lines
}
// marks every element in place: data-rb carries the machine reading, rb-gap marks what a machine cannot read; safe to run again
export function mark(root) {
const lines = read(root)
for (const l of lines) {
let target = l.el
if (l.el.parentElement?.classList.contains('rb-wrap')) target = l.el.parentElement
else if (['IMG', 'INPUT', 'TEXTAREA', 'SELECT'].includes(l.el.tagName)) {
// pseudo-elements cannot sit on replaced elements such as img and input, so each gets a span to carry the label
const wrap = document.createElement('span')
wrap.className = 'rb-wrap'
l.el.replaceWith(wrap)
wrap.append(l.el)
target = wrap
}
target.dataset.rb = `${l.tag}${l.gap ? ' · ' + l.text : ''}`
target.classList.toggle('rb-gap', !!l.gap)
l.node = target
}
return lines
}