import type { ThemeExtractionResult } from '@/schema/theme-extraction'; import { baseCSS } from '@/server/lib/styles/base-css'; import { themeVariableGenerator } from '@/server/lib/styles/theme-variables'; export interface ThemeCustomizationOptions { enableCache?: boolean; validateColors?: boolean; checkAccessibility?: boolean; performanceMode?: boolean; } export interface ThemeValidationResult { isValid: boolean; errors: string[]; warnings: string[]; } export interface CSSGenerationResult { fullCSS: string; themeCSS: string; baseCSS: string; cacheHit: boolean; generationTimeMs: number; } interface ThemeCacheEntry { css: string; themeCSS: string; timestamp: number; cacheKey: string; } export class ThemeCustomizer { private cache = new Map(); private readonly CACHE_TTL_MS = 1000 * 60 * 60; // 1時間 private readonly MAX_CACHE_SIZE = 100; constructor(private options: ThemeCustomizationOptions = {}) { this.options = { enableCache: true, validateColors: true, checkAccessibility: true, performanceMode: false, ...options, }; } generateThemedCSS(theme: ThemeExtractionResult): CSSGenerationResult { const startTime = performance.now(); try { // バリデーション実行 if (this.options.validateColors) { const validation = this.validateTheme(theme); if (!validation.isValid) { throw new Error(`テーマバリデーションエラー: ${validation.errors.join(', ')}`); } } // キャッシュキー生成 const cacheKey = this.generateCacheKey(theme); // キャッシュチェック if (this.options.enableCache) { const cached = this.getCachedResult(cacheKey); if (cached) { return { fullCSS: cached.css, themeCSS: cached.themeCSS, baseCSS, cacheHit: true, generationTimeMs: performance.now() - startTime, }; } } // CSS生成 const themeCSS = themeVariableGenerator.generateThemeCSS(theme); const fullCSS = this.combineCSS(baseCSS, themeCSS); // キャッシュ保存 if (this.options.enableCache) { this.setCachedResult(cacheKey, fullCSS, themeCSS); } const generationTimeMs = performance.now() - startTime; return { fullCSS, themeCSS, baseCSS, cacheHit: false, generationTimeMs, }; } catch (error) { console.error('[ThemeCustomizer] CSS生成エラー:', error); // フォールバック: ベースCSSのみ返す return { fullCSS: baseCSS, themeCSS: '', baseCSS, cacheHit: false, generationTimeMs: performance.now() - startTime, }; } } validateTheme(theme: ThemeExtractionResult): ThemeValidationResult { const errors: string[] = []; const warnings: string[] = []; try { // カラーコード形式の検証 const colorEntries = Object.entries(theme.colors); for (const [key, color] of colorEntries) { if (!this.isValidHexColor(color)) { errors.push(`${key}: 無効なカラーコード形式 (${color})`); } } // アクセシビリティチェック if (this.options.checkAccessibility) { const accessibilityWarnings = this.checkColorAccessibility(theme); warnings.push(...accessibilityWarnings); } return { isValid: errors.length === 0, errors, warnings, }; } catch (error) { console.error('[ThemeCustomizer] バリデーションエラー:', error); return { isValid: false, errors: ['バリデーション処理中にエラーが発生しました'], warnings: [], }; } } private isValidHexColor(color: string): boolean { const hexColorRegex = /^#[0-9A-Fa-f]{6}$/; return hexColorRegex.test(color); } private checkColorAccessibility(theme: ThemeExtractionResult): string[] { const warnings: string[] = []; try { // 主要な色の組み合わせのコントラスト比をチェック const contrastPairs = [ { fg: theme.colors.primary_text_color, bg: theme.colors.primary_background_color, name: 'プライマリテキスト/背景' }, { fg: theme.colors.secondary_text_color, bg: theme.colors.secondary_background_color, name: 'セカンダリテキスト/背景' }, { fg: theme.colors.inverse_text_color, bg: theme.colors.tertiary_background_color, name: '反転テキスト/背景' }, ]; for (const pair of contrastPairs) { const contrast = this.calculateContrastRatio(pair.fg, pair.bg); if (contrast < 4.5) { warnings.push(`${pair.name}: コントラスト比が低すぎます (${contrast.toFixed(2)}:1)`); } } } catch (error) { console.warn('[ThemeCustomizer] アクセシビリティチェック中のエラー:', error); } return warnings; } private calculateContrastRatio(color1: string, color2: string): number { try { const luminance1 = this.getLuminance(color1); const luminance2 = this.getLuminance(color2); const lighter = Math.max(luminance1, luminance2); const darker = Math.min(luminance1, luminance2); return (lighter + 0.05) / (darker + 0.05); } catch (error) { console.warn('コントラスト比計算エラー:', error); return 1; } } private getLuminance(hex: string): number { try { const rgb = this.hexToRgb(hex); if (!rgb) return 0; const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((c) => { c = c / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } catch (error) { console.warn('輝度計算エラー:', error); return 0; } } private hexToRgb(hex: string): { r: number; g: number; b: number } | null { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16), } : null; } private generateCacheKey(theme: ThemeExtractionResult): string { // テーマの内容をハッシュ化してキャッシュキーを生成 const themeString = JSON.stringify(theme.colors); return this.simpleHash(themeString); } private simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // 32bit整数に変換 } return Math.abs(hash).toString(36); } private getCachedResult(cacheKey: string): ThemeCacheEntry | null { const entry = this.cache.get(cacheKey); if (!entry) { return null; } // TTL チェック if (Date.now() - entry.timestamp > this.CACHE_TTL_MS) { this.cache.delete(cacheKey); return null; } return entry; } private setCachedResult(cacheKey: string, fullCSS: string, themeCSS: string): void { // キャッシュサイズ制限 if (this.cache.size >= this.MAX_CACHE_SIZE) { // 最も古いエントリを削除 const oldestKey = this.cache.keys().next().value; if (oldestKey) { this.cache.delete(oldestKey); } } this.cache.set(cacheKey, { css: fullCSS, themeCSS, timestamp: Date.now(), cacheKey, }); } private combineCSS(base: string, theme: string): string { return `${theme}\n\n${base}`; } // キャッシュ管理メソッド clearCache(): void { this.cache.clear(); } getCacheStats(): { size: number; maxSize: number; hitRate?: number } { return { size: this.cache.size, maxSize: this.MAX_CACHE_SIZE, }; } // デバッグ用メソッド debugTheme(theme: ThemeExtractionResult): { validation: ThemeValidationResult; cacheKey: string; generatedVariables: string; } { const validation = this.validateTheme(theme); const cacheKey = this.generateCacheKey(theme); const generatedVariables = themeVariableGenerator.generateThemeCSS(theme); return { validation, cacheKey, generatedVariables, }; } } // デフォルトインスタンス export const defaultThemeCustomizer = new ThemeCustomizer();