Spaces:
Sleeping
Sleeping
| 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<string, ThemeCacheEntry>(); | |
| 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(); | |