FE_Dev / server /lib /theme /theme-customizer.ts
GitHub Actions
Deploy from GitHub Actions [dev] - 2025-10-31 07:28:50
68f7925
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();