export const createThemeRuntime = (dependencies) => { const { fsPromises, path, themesDir, maxThemeJsonBytes, logger, } = dependencies; const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0; const isValidThemeColor = (value) => isNonEmptyString(value); const normalizeThemeJson = (raw) => { if (!raw || typeof raw !== 'object') { return null; } const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null; const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null; if (!metadata || !colors) { return null; } const id = metadata.id; const name = metadata.name; const variant = metadata.variant; if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) { return null; } const primary = colors.primary; const surface = colors.surface; const interactive = colors.interactive; const status = colors.status; const syntax = colors.syntax; const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null; const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null; if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) { return null; } // Minimal fields required by CSSVariableGenerator and diff/syntax rendering. const required = [ primary.base, primary.foreground, surface.background, surface.foreground, surface.muted, surface.mutedForeground, surface.elevated, surface.elevatedForeground, surface.subtle, interactive.border, interactive.selection, interactive.selectionForeground, interactive.focusRing, interactive.hover, status.error, status.errorForeground, status.errorBackground, status.errorBorder, status.warning, status.warningForeground, status.warningBackground, status.warningBorder, status.success, status.successForeground, status.successBackground, status.successBorder, status.info, status.infoForeground, status.infoBackground, status.infoBorder, syntaxBase.background, syntaxBase.foreground, syntaxBase.keyword, syntaxBase.string, syntaxBase.number, syntaxBase.function, syntaxBase.variable, syntaxBase.type, syntaxBase.comment, syntaxBase.operator, syntaxHighlights.diffAdded, syntaxHighlights.diffRemoved, syntaxHighlights.lineNumber, ]; if (!required.every(isValidThemeColor)) { return null; } const tags = Array.isArray(metadata.tags) ? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0) : []; return { ...raw, metadata: { ...metadata, id: id.trim(), name: name.trim(), description: typeof metadata.description === 'string' ? metadata.description : '', version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0', variant, tags, }, }; }; const readCustomThemesFromDisk = async () => { try { const entries = await fsPromises.readdir(themesDir, { withFileTypes: true }); const themes = []; const seen = new Set(); for (const entry of entries) { if (!entry.isFile() && !entry.isSymbolicLink()) continue; if (!entry.name.toLowerCase().endsWith('.json')) continue; const filePath = path.join(themesDir, entry.name); try { const stat = await fsPromises.stat(filePath); if (!stat.isFile()) continue; if (stat.size > maxThemeJsonBytes) { logger.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`); continue; } const rawText = await fsPromises.readFile(filePath, 'utf8'); const parsed = JSON.parse(rawText); const normalized = normalizeThemeJson(parsed); if (!normalized) { logger.warn(`[themes] Skip ${entry.name}: invalid theme JSON`); continue; } const id = normalized.metadata.id; if (seen.has(id)) { logger.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`); continue; } seen.add(id); themes.push(normalized); } catch (error) { logger.warn(`[themes] Failed to read ${entry.name}:`, error); } } return themes; } catch (error) { // Missing dir is fine. if (error && typeof error === 'object' && error.code === 'ENOENT') { return []; } logger.warn('[themes] Failed to list custom themes dir:', error); return []; } }; return { normalizeThemeJson, readCustomThemesFromDisk, }; };