Last active
December 29, 2023 20:01
-
-
Save kphrx/04f0600394b406e4ad46c9046d068025 to your computer and use it in GitHub Desktop.
shields badge modify a11y color contrast ratio https://a11y-shields-badge.kphrx.workers.dev/
This file contains 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
const RED = 2126; | |
const GREEN = 7152; | |
const BLUE = 722; | |
/** | |
* @typedef {Object} hueValue | |
* @property {'hue'} type | |
* @property {number} value | |
*/ | |
/** | |
* @typedef {Object} rgbValue | |
* @property {'rgb'} type | |
* @property {[number, number, number]} value | |
*/ | |
/** | |
* @param {hueValue|rgbValue} value | |
*/ | |
const relativeLumination = ({type, value}) => { | |
switch (type) { | |
case 'hue': return Math.floor(relativeLuminationForHue(value) / 6000); | |
case 'rgb': return Math.floor(relativeLuminationForRGB(value) / 25500); | |
} | |
}; | |
/** | |
* @param {number} hue | |
*/ | |
const relativeLuminationForHue = (hue) => { | |
while (hue >= 360) { | |
hue - 360; | |
} | |
const hue60 = hue % 60; | |
switch ((hue - hue60) / 60) { | |
case 0: // up green | |
return 60 * RED + hue60 * GREEN + 0 * BLUE; | |
case 1: // down red | |
return (60 - hue60) * RED + 60 * GREEN + 0 * BLUE; | |
case 2: // up blue | |
return 0 * RED + 60 * GREEN + hue60 * BLUE; | |
case 3: // down green | |
return 0 * RED + (60 - hue60) * GREEN + 60 * BLUE; | |
case 4: // up red | |
return hue60 * RED + 0 * GREEN + 60 * BLUE; | |
case 5: // down blue | |
return 60 * RED + 0 * GREEN + (60 - hue60) * BLUE; | |
} | |
}; | |
/** | |
* @param {[number, number, number]} rgb | |
*/ | |
const relativeLuminationForRGB = ([red, green, blue]) => { | |
return red * RED + green * GREEN + blue * BLUE; | |
}; | |
/** | |
* @param {number} relLum | |
* @returns {[number | undefined, boolean]} | |
*/ | |
const wcagRatioLightness = (relLum, def = 50) => { | |
const lig = Math.floor(relLum * def * 2 / 100); | |
if (18 <= lig && lig <= 25) { // 4.5:1 | |
return [18 / lig * def, false]; | |
} | |
if (lig <= 18) { | |
return [def, false]; | |
} | |
if (30 <= lig && lig <= 42) { // 3:1 | |
return [30 / lig * def, true]; | |
} | |
if (lig <= 30) { | |
return [def, true]; | |
} | |
return [, false]; | |
}; | |
/** | |
* @param {string} hueValue | |
* @param {string} satValue | |
* @param {string} ligValue | |
* @returns {[string, boolean, boolean]} | |
*/ | |
const hslFill = (hueValue, satValue, ligValue) => { | |
const defLig = Number(ligValue); | |
const relLum = relativeLumination({type: 'hue', value: Number(hueValue)}); | |
let [lig, isBold] = wcagRatioLightness(relLum, defLig); | |
let isBlack = false; | |
if (lig == null) { | |
isBlack = true; | |
lig = defLig; | |
} | |
return [`hsl(${hueValue} ${satValue}% ${lig}%)`, isBold, isBlack]; | |
}; | |
/** | |
* @param {number} relLum | |
* @returns {[boolean, boolean]} | |
*/ | |
const wcagRatio = (relLum) => { | |
if (relLum <= 18) { // 4.5:1 | |
return [false, false]; | |
} | |
if (relLum <= 30) { // 3:1 | |
return [true, false]; | |
} | |
return [false, true]; | |
}; | |
/** | |
* @param {string} rValue | |
* @param {string} gValue | |
* @param {string} bValue | |
* @returns {[boolean, boolean]} | |
*/ | |
const rgbFill = (rValue, gValue, bValue) => { | |
const relLum = relativeLumination({type: 'rgb', value: [parseInt(rValue, 16), parseInt(gValue, 16), parseInt(bValue, 16)]}); | |
let [isBold, isBlack] = wcagRatio(relLum); | |
return [isBold, isBlack]; | |
}; | |
export default { | |
/** | |
* @typedef {Object} ExecutionContext | |
* @property {(promise: Promise) => void} waitUntil | |
* @property {() => void} passThroughOnException | |
*/ | |
/** | |
* @param {Request} request | |
* @param {{[key: string]: string}} env | |
* @param {ExecutionContext | undefined} ctx | |
*/ | |
async fetch(request, env, ctx) { | |
let reqUrl = new URL(request.url); | |
reqUrl.host = 'img.shields.io'; | |
let res = await fetch(reqUrl); | |
if (res.redirected) { | |
return Response.redirect(res.url, 302); | |
} | |
let boldText = false; | |
let blackText = false; | |
let nth = 0; | |
return new HTMLRewriter() | |
.on('rect:nth-of-type(2)', { | |
element(element) { | |
const fill = element.getAttribute('fill'); | |
const hslMatched = fill.match(/hsl\(([0-9]+),? ([0-9]+)%,? ([0-9]+)%\)/); | |
if (hslMatched != null) { | |
let [color, hueValue, satValue, ligValue] = hslMatched; | |
[color, boldText, blackText] = hslFill(hueValue, satValue, ligValue); | |
element.setAttribute('fill', color); | |
return; | |
} | |
const rgbMatched = fill.match(/#(([0-9a-fA-F][0-9a-fA-F])([0-9a-fA-F][0-9a-fA-F])([0-9a-fA-F][0-9a-fA-F])|([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F]))/); | |
if (rgbMatched != null) { | |
const [_hex, _value, rValue, gValue, bValue] = rgbMatched; | |
[boldText, blackText] = rgbFill(rValue, gValue, bValue); | |
return; | |
} | |
}, | |
}) | |
.on('text:not([aria-hidden="true"])', { | |
element(element) { | |
nth++; | |
if (nth < 2) { | |
return; | |
} | |
if (boldText) { | |
element.setAttribute('font-weight', 'bold'); | |
} | |
if (blackText) { | |
element.setAttribute('fill', '#000'); | |
} | |
}, | |
}) | |
.transform(res); | |
}, | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment