Files
openchamber/packages/web/server/lib/opencode/theme-runtime.js
T

168 lines
4.9 KiB
JavaScript

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,
};
};