[사이드 프로젝트] 웹 접근성 검사 도구 #7 CSS validator

홍준·2024년 1월 23일
post-thumbnail

> CSS valiator

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)


> color validator

css 색상을 검사하는 color validator. 배경색이 있을 때, 테두리가 존재한다면 테두리 색을 비교, 텍스트가 존재한다면 텍스트 색을 비교한다. 두 색의 명도 대비가 4.5 밑이라면 suggestion을 추가하고 알맞은 색상을 추천한다. 까지가 원래 계획이었는데 생각보다 어려움이 많았다.

backgroundbackground-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

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
profile
행복하세요.

0개의 댓글