Merge commit from fork

* fix(frontend): avoid recursive reference on theme variables

* fix(theme): filter compiled theme properties to include only valid themeProps
This commit is contained in:
かっこかり 2026-05-20 22:05:15 +09:00 committed by GitHub
commit 602a46cb78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -29,6 +29,8 @@ export type Theme = {
export type CompiledTheme = Record<string, string>;
const MAX_THEME_REFERENCE_DEPTH = 4;
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
export const getBuiltinThemes = () => Promise.all(
@ -56,45 +58,68 @@ export const getBuiltinThemes = () => Promise.all(
].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
);
export function compile(theme: Theme): CompiledTheme {
function getColor(val: string): tinycolor.Instance {
if (val[0] === '@') { // ref (prop)
return getColor(theme.props[val.substring(1)]);
} else if (val[0] === '$') { // ref (const)
return getColor(theme.props[val]);
} else if (val[0] === ':') { // func
const parts = val.split('<');
const funcTxt = parts.shift();
const argTxt = parts.shift();
if (funcTxt && argTxt) {
const func = funcTxt.substring(1);
const arg = parseFloat(argTxt);
const color = getColor(parts.join('<'));
switch (func) {
case 'darken': return color.darken(arg);
case 'lighten': return color.lighten(arg);
case 'alpha': return color.setAlpha(arg);
case 'hue': return color.spin(arg);
case 'saturate': return color.saturate(arg);
}
}
}
// other case
return tinycolor(val);
function getThemeReferenceColor(theme: Theme, key: string, stack: string[], depth: number): tinycolor.Instance {
if (depth >= MAX_THEME_REFERENCE_DEPTH) {
throw new Error('Theme reference limit exceeded');
}
if (stack.includes(key)) {
throw new Error('Theme contains circular references');
}
const nextValue = theme.props[key];
if (typeof nextValue !== 'string') {
throw new Error(`Theme references missing property: ${key}`);
}
return getColor(theme, nextValue, [...stack, key], depth + 1);
}
function getColor(theme: Theme, val: string, stack: string[] = [], depth = 0): tinycolor.Instance {
if (depth >= MAX_THEME_REFERENCE_DEPTH) {
throw new Error('Theme reference limit exceeded');
}
if (val[0] === '@') { // ref (prop)
return getThemeReferenceColor(theme, val.substring(1), stack, depth);
} else if (val[0] === '$') { // ref (const)
return getThemeReferenceColor(theme, val, stack, depth);
} else if (val[0] === ':') { // func
const parts = val.split('<');
const funcTxt = parts.shift();
const argTxt = parts.shift();
if (funcTxt && argTxt) {
const func = funcTxt.substring(1);
const arg = parseFloat(argTxt);
const color = getColor(theme, parts.join('<'), stack, depth + 1);
switch (func) {
case 'darken': return color.darken(arg);
case 'lighten': return color.lighten(arg);
case 'alpha': return color.setAlpha(arg);
case 'hue': return color.spin(arg);
case 'saturate': return color.saturate(arg);
}
}
}
// other case
return tinycolor(val);
}
export function compile(theme: Theme): CompiledTheme {
const props = {} as CompiledTheme;
for (const [k, v] of Object.entries(theme.props)) {
if (k.startsWith('$')) continue; // ignore const
props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(theme, v));
}
return props;
return Object.fromEntries(
Object.entries(props).filter(([key]) => themeProps.includes(key)),
) as CompiledTheme;
}
function genValue(c: tinycolor.Instance): string {