CSS validator가 비교적 쉬운 것 같지만 모든 선택자의 rule을 검사해야 하기에 헷갈리는 부분이 많았다. 특히 초반에 직렬화 문제로 대부분의 값을 Object.values로 처리하다 보니 코드 가독성도 좋지 못했다.
import { CSSNode, CSSNodeValue, CSSSuggestion } from 'utils/types';
import { colorValidator } from './color';
export const CSSValidator = (parsedCSSCode: CSSNode): CSSNode => {
if (!parsedCSSCode) return parsedCSSCode;
Object.values(parsedCSSCode).forEach((node: CSSNodeValue | string) => {
if (typeof node === 'string') return;
if (node.type !== 'rule') return;
const suggestion = getSuggestion(node);
colorValidator(suggestion);
});
return parsedCSSCode;
}
const getSuggestion = (node: CSSNodeValue): CSSSuggestion => {
if (node.suggestion !== undefined) return node.suggestion;
const suggestion = new CSSSuggestion(node);
node.suggestion = suggestion;
return suggestion;
}
(↑/validator/css/index.ts)
css 색상을 검사하는 color validator. 배경색이 있을 때, 테두리가 존재한다면 테두리 색을 비교, 텍스트가 존재한다면 텍스트 색을 비교한다. 두 색의 명도 대비가 4.5 밑이라면 suggestion을 추가하고 알맞은 색상을 추천한다. 까지가 원래 계획이었는데 생각보다 어려움이 많았다.
background 와 background-color 를 구분하는 것부터 이미 앞에서 제안을 추가하여 background-color가 들어가 있을 때 그 다음부턴 추가하지 않는 것도 고려해야 했으며 가장 어려웠던 부분은 색상 추천이었다.
처음엔 tinycolor2 를 사용하여 색상을 비교한 후 명도 대비가 낮다면 한쪽 색상을 조정하여 명도 대비가 4.5 이상이 될 때까지 루프를 돌리는 코드를 작성했다. 그러나 생각보다 루프가 많이 돌며 디테일한 색상 조정이 힘들자, 그냥 배경색의 밝기가 128 초과면 검은색을 추천, 이하라면 하얀색을 추천하기로 했다.
import tinycolor from "tinycolor2";
import colorNames from 'color-name-list';
import { CSSNode, CSSNodeValue, CSSSuggestion } from 'utils/types';
export const colorValidator = (suggestion: CSSSuggestion) => {
const node = suggestion.getNode();
const suggestionNode = suggestion.getSuggestionNode();
const rules = Object.values(node.value) as CSSNodeValue[];
const colorNameArray = colorNames.map(color => color.name);
const colorNameString = '\\b(' + colorNameArray.join('|') + ')\\b';
const hexReg = /#([0-9a-f]{3}){1,2}\b/i;
const rgbReg = /rgb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)/i;
const colorNameReg = new RegExp(colorNameString, 'i');
let backgroundColor;
let borderColor;
let textColor;
// 색상 추출
rules.forEach(({name, value}) => {
value = value as string; // 선택자 { string: string } 라는 전제
if (name === 'background-color') return backgroundColor = value;
if (name === 'background') {
let hex = value.match(hexReg);
if (hex !== null) return backgroundColor = hex[0];
let rgb = value.match(rgbReg);
if (rgb !== null) return backgroundColor = rgb[0];
let colorName = value.match(colorNameReg);
if (colorName !== null) return backgroundColor = colorName[0];
}
if (name === 'border') {
let borderToken = value.split(" ");
if (borderToken.length > 2) {
return borderColor = value.split(" ")[2];
}
}
if (name === 'color') return textColor = value;
});
// 배경색이 없으면 검사 종료
if (!backgroundColor) return;
// suggestionNode value에 이미 background-color 추가했는지 여부
let isBackgroundColorInSuggestionNode = false;
// 추가할 value obj & 추가할 rule 넘버링 len
const value = suggestionNode.value as CSSNode;
let num = Object.keys(value).length;
// 테두리 검사
if (borderColor && !isValidContrastRatio(backgroundColor, borderColor)) {
if (!isBackgroundColorInSuggestionNode) {
value['rule_' + num++] = {
name: 'background-color',
type: 'attr',
value: backgroundColor,
};
isBackgroundColorInSuggestionNode = true;
}
value['rule_' + num++] = {
name: 'border',
type: 'attr',
value: getRecommendColor(backgroundColor, borderColor),
};
suggestion.addDescription(`
The color contrast ratio between the background and the border should be at least 4.5.
`);
}
// 텍스트 검사
if (textColor && !isValidContrastRatio(backgroundColor, textColor)) {
const value = suggestionNode.value as CSSNode;
if (!isBackgroundColorInSuggestionNode) {
value['rule_' + num++] = {
name: 'background-color',
type: 'attr',
value: backgroundColor,
};
isBackgroundColorInSuggestionNode = true;
}
value['rule_' + num++] = {
name: 'color',
type: 'attr',
value: getRecommendColor(backgroundColor, textColor),
};
suggestion.addDescription(`
The color contrast ratio between the background and the text should be at least 4.5.
`);
}
}
const isValidContrastRatio = (color1: string, color2: string) => {
let c1 = tinycolor(color1);
let c2 = tinycolor(color2);
const contrastRatio = tinycolor.readability(c1, c2);
if (contrastRatio < 4.5) return false;
return true;
}
const getRecommendColor = (color1: string, color2: string) => {
let contrastRatio = tinycolor.readability(color1, color2);
if (contrastRatio < 4.5) {
let brightness = tinycolor(color1).getBrightness();
return brightness > 128 ? '#000000' : '#FFFFFF';
}
return color2;
}
(↑/validator/css/color.ts)
text validator의 경우는 간단했다. px을 사용하는지 검사하여 em, rem으로 변환해 주면 되었다. 아래는 text validator 코드다.
import { CSSNode, CSSNodeValue, CSSSuggestion } from 'utils/types';
export const textValidator = (suggestion: CSSSuggestion) => {
const node = suggestion.getNode();
const suggestionNode = suggestion.getSuggestionNode();
const rules = Object.values(node.value) as CSSNodeValue[];
const pxToEm = (px: number) => {
let em;
if (px % 16 !== 0) {
em = (px / 16).toFixed(1);
} else {
em = px / 16;
}
return em;
}
let flag = false;
rules.forEach(({name, value}) => {
value = value as string; // 선택자 { string: string } 라는 전제
if (name === 'font-size') {
const valueTokens = value.split('px');
if (valueTokens.length > 1) {
const suggestionValue = suggestionNode.value as CSSNode;
let num = Object.keys(suggestionValue).length;
suggestionValue['rule_' + num++] = {
name: name,
type: 'attr',
value: pxToEm(Number(valueTokens[0])) + 'em',
};
suggestion.addDescription(`
You should use 'em' or 'rem' units for font size instead of 'px'.
`);
}
}
if (name === 'line-height' || name === 'letter-spacing' || name === 'word-spacing') {
const valueTokens = value.split('px');
if (valueTokens.length > 1) {
const suggestionValue = suggestionNode.value as CSSNode;
let num = Object.keys(suggestionValue).length;
suggestionValue['rule_' + num++] = {
name: name,
type: 'attr',
value: pxToEm(Number(valueTokens[0])) + 'em',
};
if (flag) {
suggestion.addDescription(`
For line-height, letter-spacing, and word-spacing, you should use 'em' or 'rem' units instead of 'px'.
`);
}
}
}
});
}
(↑/validator/css/text.ts)
이렇게 대부분의 작업은 모두 끝났다. 이제 로딩 바(할지 말지 고민 중) 정도랑 alert이나 예외 처리, 그리고 검사하는 항목을 나타내는 팝업 정도 만들 계획이다. 그 후 배포까지 하면 탈도 많고 배움도 많았던 사이드 프로젝트가 끝나지 않을까 싶다.
📦web-accessibility-validator
┣ 📂public
┃ ┗ 📜index.html
┣ 📂src
┃ ┣ 📂components
┃ ┃ ┣ 📂main
┃ ┃ ┃ ┣ 📜Guideline.tsx
┃ ┃ ┃ ┣ 📜Title.tsx
┃ ┃ ┃ ┗ 📜Uploader.tsx
┃ ┃ ┣ 📂result
┃ ┃ ┃ ┣ 📜Box.tsx
┃ ┃ ┃ ┣ 📜BoxTitle.tsx
┃ ┃ ┃ ┣ 📜CodeBlock.tsx
┃ ┃ ┃ ┣ 📜Left.tsx
┃ ┃ ┃ ┣ 📜Result.tsx
┃ ┃ ┃ ┗ 📜Right.tsx
┃ ┃ ┗ 📜Header.tsx
┃ ┣ 📂highlighter
┃ ┃ ┣ 📜css.tsx
┃ ┃ ┣ 📜Highlighter.tsx
┃ ┃ ┗ 📜html.tsx
┃ ┣ 📂pages
┃ ┃ ┣ 📜main.tsx
┃ ┃ ┗ 📜result.tsx
┃ ┣ 📂reducers
┃ ┃ ┣ 📜codeSlice.tsx
┃ ┃ ┗ 📜store.tsx
┃ ┣ 📂utils
┃ ┃ ┣ 📜cssjson.ts
┃ ┃ ┣ 📜types.ts
┃ ┃ ┗ 📜util.ts
┃ ┣ 📂validator
┃ ┃ ┣ 📂css
┃ ┃ ┃ ┣ 📜color.ts
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜text.ts
┃ ┃ ┗ 📂html
┃ ┃ ┃ ┣ 📜a.ts
┃ ┃ ┃ ┣ 📜frame.ts
┃ ┃ ┃ ┣ 📜head.ts
┃ ┃ ┃ ┣ 📜html.ts
┃ ┃ ┃ ┣ 📜img.ts
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┣ 📜table.ts
┃ ┃ ┃ ┣ 📜videoAndAudio.ts
┃ ┃ ┃ ┗ 📜xss.ts
┃ ┣ 📜App.tsx
┃ ┣ 📜globals.css
┃ ┣ 📜index.tsx
┃ ┗ 📜react-app-env.d.ts
┣ 📜.gitignore
┣ 📜package-lock.json
┣ 📜package.json
┣ 📜postcss.config.js
┣ 📜README.md
┣ 📜tailwind.config.js
┣ 📜tsconfig.json
┣ 📜web-accessibility-validator.pptx
┗ 📜yarn.lock