Files
2026-04-01 10:30:52 +09:00

389 lines
9.8 KiB
TypeScript

/**
* G7 DevTools Tailwind 빌드 검증기
*
* 레이아웃 JSON에서 사용된 Tailwind 클래스와 빌드된 CSS를 비교하여
* 퍼지되었을 수 있는 클래스를 감지합니다.
*
* @module TailwindValidator
*/
/**
* 검증 결과
*/
export interface TailwindValidationResult {
/** 검증 성공 여부 */
valid: boolean;
/** 총 사용된 클래스 수 */
totalClasses: number;
/** 유효한 클래스 수 */
validClasses: number;
/** 퍼지 가능성 있는 클래스 */
potentiallyPurged: PurgedClassInfo[];
/** 동적 클래스 (검증 스킵) */
dynamicClasses: string[];
/** 알 수 없는 클래스 */
unknownClasses: string[];
/** 검증 시간 */
timestamp: number;
}
/**
* 퍼지된 클래스 정보
*/
export interface PurgedClassInfo {
/** 클래스명 */
className: string;
/** 사용된 위치 (파일 또는 컴포넌트) */
usedIn: string[];
/** 퍼지 가능성 이유 */
reason: 'not-in-css' | 'dynamic-value' | 'custom-class';
/** 권장 사항 */
suggestion?: string;
}
/**
* Tailwind 클래스 패턴
*/
const TAILWIND_PATTERNS = {
// 레이아웃
layout: /^(flex|grid|block|inline|hidden|container|box-|float-|clear-|object-|overflow-|overscroll-|z-)/,
// 간격
spacing: /^(p[xytblr]?-|m[xytblr]?-|space-[xy]-|gap-)/,
// 크기
sizing: /^(w-|h-|min-w-|min-h-|max-w-|max-h-|size-)/,
// 타이포그래피
typography: /^(text-|font-|tracking-|leading-|list-|placeholder-|truncate|break-|whitespace-|align-)/,
// 배경
background: /^(bg-|from-|via-|to-|gradient-)/,
// 테두리
border: /^(border|rounded|ring|outline|divide|shadow)/,
// 효과
effects: /^(opacity-|mix-|backdrop-|blur-|brightness-|contrast-|grayscale|hue-|invert|saturate|sepia|drop-)/,
// 필터
filters: /^(filter|blur|brightness|contrast)/,
// 테이블
table: /^(table|border-collapse|border-spacing)/,
// 트랜지션
transition: /^(transition|duration-|ease-|delay-|animate-)/,
// 변환
transform: /^(transform|scale-|rotate-|translate-|skew-|origin-)/,
// 상호작용
interactivity: /^(cursor-|pointer-|resize|select-|scroll-|snap-|touch-|will-)/,
// 접근성
accessibility: /^(sr-only|not-sr-only)/,
// 색상
colors: /^(text-|bg-|border-|ring-|outline-|fill-|stroke-)(inherit|current|transparent|black|white|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)/,
};
/**
* 변형 접두사
*/
const VARIANTS = [
'hover',
'focus',
'active',
'visited',
'disabled',
'first',
'last',
'odd',
'even',
'group-hover',
'peer-hover',
'dark',
'sm',
'md',
'lg',
'xl',
'2xl',
'portable',
'desktop',
'print',
'motion-safe',
'motion-reduce',
];
/**
* Tailwind 빌드 검증기
*/
export class TailwindValidator {
private static instance: TailwindValidator | null = null;
private loadedCssRules: Set<string> = new Set();
private cssLoaded: boolean = false;
private constructor() {
// 싱글톤
}
/**
* 싱글톤 인스턴스 반환
*/
static getInstance(): TailwindValidator {
if (!TailwindValidator.instance) {
TailwindValidator.instance = new TailwindValidator();
}
return TailwindValidator.instance;
}
/**
* 빌드된 CSS에서 클래스 목록 로드
*/
async loadCssClasses(): Promise<void> {
if (this.cssLoaded) return;
this.loadedCssRules.clear();
// 모든 스타일시트에서 클래스 추출
try {
const styleSheets = document.styleSheets;
for (const sheet of styleSheets) {
try {
// CORS 제한된 스타일시트는 스킵
if (!sheet.cssRules) continue;
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule) {
// 셀렉터에서 클래스 추출
const classes = this.extractClassesFromSelector(rule.selectorText);
classes.forEach(c => this.loadedCssRules.add(c));
}
}
} catch {
// 접근 불가 스타일시트 스킵
}
}
this.cssLoaded = true;
console.log(`[TailwindValidator] ${this.loadedCssRules.size}개 CSS 클래스 로드됨`);
} catch (error) {
console.error('[TailwindValidator] CSS 로드 실패:', error);
}
}
/**
* CSS 셀렉터에서 클래스명 추출
*/
private extractClassesFromSelector(selector: string): string[] {
const classRegex = /\.([a-zA-Z_-][a-zA-Z0-9_-]*)/g;
const classes: string[] = [];
let match;
while ((match = classRegex.exec(selector)) !== null) {
// 이스케이프된 문자 처리 (예: hover\:bg-blue-500)
const className = match[1].replace(/\\/g, '');
classes.push(className);
}
return classes;
}
/**
* 레이아웃 JSON에서 클래스 추출
*/
extractClassesFromLayout(layout: any): string[] {
const classes = new Set<string>();
const traverse = (node: any) => {
if (!node) return;
// props.className에서 추출
if (node.props?.className) {
const classNames = this.parseClassName(node.props.className);
classNames.forEach(c => classes.add(c));
}
// props.class에서 추출 (일부 컴포넌트)
if (node.props?.class) {
const classNames = this.parseClassName(node.props.class);
classNames.forEach(c => classes.add(c));
}
// children 순회
if (Array.isArray(node.children)) {
node.children.forEach(traverse);
}
// slots 순회
if (node.slots) {
Object.values(node.slots).forEach((slot: any) => {
if (Array.isArray(slot)) {
slot.forEach(traverse);
}
});
}
};
traverse(layout);
return Array.from(classes);
}
/**
* className 문자열에서 개별 클래스 파싱
*/
private parseClassName(className: string): string[] {
if (!className) return [];
// 바인딩 표현식 제거 ({{...}})
const withoutBindings = className.replace(/\{\{[^}]+\}\}/g, '');
// 공백으로 분리
return withoutBindings
.split(/\s+/)
.filter(Boolean)
.filter(c => !c.includes('{{') && !c.includes('}}'));
}
/**
* 클래스가 Tailwind 패턴인지 확인
*/
isTailwindClass(className: string): boolean {
// 변형 접두사 제거
let baseClass = className;
for (const variant of VARIANTS) {
if (className.startsWith(`${variant}:`)) {
baseClass = className.slice(variant.length + 1);
break;
}
}
// 패턴 매칭
for (const pattern of Object.values(TAILWIND_PATTERNS)) {
if (pattern.test(baseClass)) {
return true;
}
}
// 일반적인 Tailwind 유틸리티 패턴
if (/^-?[a-z]+-[a-z0-9/[\]]+$/.test(baseClass)) {
return true;
}
return false;
}
/**
* 동적 값이 포함된 클래스인지 확인
*/
isDynamicClass(className: string): boolean {
// 바인딩 표현식
if (className.includes('{{') || className.includes('}}')) {
return true;
}
// 대괄호 표기법 (arbitrary values)
if (className.includes('[') && className.includes(']')) {
return true;
}
return false;
}
/**
* 클래스 검증
*/
async validateClasses(classes: string[]): Promise<TailwindValidationResult> {
await this.loadCssClasses();
const potentiallyPurged: PurgedClassInfo[] = [];
const dynamicClasses: string[] = [];
const unknownClasses: string[] = [];
let validCount = 0;
for (const className of classes) {
// 동적 클래스는 스킵
if (this.isDynamicClass(className)) {
dynamicClasses.push(className);
continue;
}
// CSS에 존재하는지 확인
if (this.loadedCssRules.has(className)) {
validCount++;
continue;
}
// Tailwind 패턴인지 확인
if (this.isTailwindClass(className)) {
// Tailwind 패턴이지만 CSS에 없음 = 퍼지됨
potentiallyPurged.push({
className,
usedIn: [],
reason: 'not-in-css',
suggestion: `safelist에 추가하거나 정적 클래스로 사용하세요: '${className}'`,
});
} else {
// Tailwind 패턴이 아님 = 커스텀 클래스
unknownClasses.push(className);
}
}
return {
valid: potentiallyPurged.length === 0,
totalClasses: classes.length,
validClasses: validCount,
potentiallyPurged,
dynamicClasses,
unknownClasses,
timestamp: Date.now(),
};
}
/**
* DOM에서 사용 중인 클래스 검증
*/
async validateDom(): Promise<TailwindValidationResult> {
const usedClasses = new Set<string>();
// 모든 요소의 클래스 수집
document.querySelectorAll('[class]').forEach(el => {
const className = el.getAttribute('class') || '';
className.split(/\s+/).filter(Boolean).forEach(c => usedClasses.add(c));
});
return this.validateClasses(Array.from(usedClasses));
}
/**
* 빠른 검증 (주요 클래스만)
*/
quickValidate(classes: string[]): { purged: string[]; valid: string[] } {
const purged: string[] = [];
const valid: string[] = [];
for (const className of classes) {
if (this.isDynamicClass(className)) continue;
if (this.loadedCssRules.has(className)) {
valid.push(className);
} else if (this.isTailwindClass(className)) {
purged.push(className);
}
}
return { purged, valid };
}
/**
* CSS 클래스 캐시 초기화
*/
clearCache(): void {
this.loadedCssRules.clear();
this.cssLoaded = false;
}
/**
* 로드된 CSS 클래스 수 반환
*/
getLoadedClassCount(): number {
return this.loadedCssRules.size;
}
}
/**
* TailwindValidator 싱글톤 인스턴스 반환
*/
export function getTailwindValidator(): TailwindValidator {
return TailwindValidator.getInstance();
}