249 lines
11 KiB
JavaScript

const chroma = require('chroma-js');
const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const colors = require('tailwindcss/colors');
const plugin = require('tailwindcss/plugin');
const flattenColorPalette = require('tailwindcss/lib/util/flattenColorPalette').default;
const generateContrasts = require(path.resolve(__dirname, ('../utils/generate-contrasts')));
const jsonToSassMap = require(path.resolve(__dirname, ('../utils/json-to-sass-map')));
// -----------------------------------------------------------------------------------------------------
// @ Utilities
// -----------------------------------------------------------------------------------------------------
/**
* Normalizes the provided theme by omitting empty values and values that
* start with "on" from each palette. Also sets the correct DEFAULT value
* of each palette.
*
* @param theme
*/
const normalizeTheme = (theme) =>
{
return _.fromPairs(_.map(_.omitBy(theme, (palette, paletteName) => paletteName.startsWith('on') || _.isEmpty(palette)),
(palette, paletteName) => [
paletteName,
{
...palette,
DEFAULT: palette['DEFAULT'] || palette[500]
}
]
));
};
// -----------------------------------------------------------------------------------------------------
// @ FUSE TailwindCSS Main Plugin
// -----------------------------------------------------------------------------------------------------
const theming = plugin.withOptions((options) => ({
addComponents,
e,
theme
}) =>
{
/**
* Create user themes object by going through the provided themes and
* merging them with the provided "default" so, we can have a complete
* set of color palettes for each user theme.
*/
const userThemes = _.fromPairs(_.map(options.themes, (theme, themeName) => [
themeName,
_.defaults({}, theme, options.themes['default'])
]));
/**
* Normalize the themes and assign it to the themes object. This will
* be the final object that we create a SASS map from
*/
let themes = _.fromPairs(_.map(userThemes, (theme, themeName) => [
themeName,
normalizeTheme(theme)
]));
/**
* Go through the themes to generate the contrasts and filter the
* palettes to only have "primary", "accent" and "warn" objects.
*/
themes = _.fromPairs(_.map(themes, (theme, themeName) => [
themeName,
_.pick(
_.fromPairs(_.map(theme, (palette, paletteName) => [
paletteName,
{
...palette,
contrast: _.fromPairs(_.map(generateContrasts(palette), (color, hue) => [
hue,
_.get(userThemes[themeName], [`on-${paletteName}`, hue]) || color
]))
}
])),
['primary', 'accent', 'warn']
)
]));
/**
* Go through the themes and attach appropriate class selectors so,
* we can use them to encapsulate each theme.
*/
themes = _.fromPairs(_.map(themes, (theme, themeName) => [
themeName,
{
selector: `".theme-${themeName}"`,
...theme
}
]));
/* Generate the SASS map using the themes object */
const sassMap = jsonToSassMap(JSON.stringify({'user-themes': themes}));
/* Write the SASS map to the file */
fs.writeFile(path.resolve(__dirname, ('../../styles/user-themes.scss')), sassMap, (err) =>
{
if ( err )
{
return console.log(err);
}
});
/**
* Iterate through the user's themes and build Tailwind components containing
* CSS Custom Properties using the colors from them. This allows switching
* themes by simply replacing a class name as well as nesting them.
*/
addComponents(
_.fromPairs(_.map(options.themes, (theme, themeName) => [
themeName === 'default' ? 'body, .theme-default' : `.theme-${e(themeName)}`,
_.fromPairs(_.flatten(_.map(flattenColorPalette(_.fromPairs(_.flatten(_.map(normalizeTheme(theme), (palette, paletteName) => [
[
e(paletteName),
palette
],
[
`on-${e(paletteName)}`,
_.fromPairs(_.map(generateContrasts(palette), (color, hue) => [hue, _.get(theme, [`on-${paletteName}`, hue]) || color]))
]
])
))), (value, key) => [[`--fuse-${e(key)}`, value], [`--fuse-${e(key)}-rgb`, chroma(value).rgb().join(',')]])))
]))
);
/**
* Generate scheme based css custom properties and utility classes
*/
const schemeCustomProps = _.map(['light', 'dark'], (colorScheme) =>
{
const isDark = colorScheme === 'dark';
const background = theme(`fuse.customProps.background.${colorScheme}`);
const foreground = theme(`fuse.customProps.foreground.${colorScheme}`);
const lightSchemeSelectors = 'body.light, .light, .dark .light';
const darkSchemeSelectors = 'body.dark, .dark, .light .dark';
return {
[(isDark ? darkSchemeSelectors : lightSchemeSelectors)]: {
/**
* If a custom property is not available, browsers will use
* the fallback value. In this case, we want to use '--is-dark'
* as the indicator of a dark theme so, we can use it like this:
* background-color: var(--is-dark, red);
*
* If we set '--is-dark' as "true" on dark themes, the above rule
* won't work because of the said "fallback value" logic. Therefore,
* we set the '--is-dark' to "false" on light themes and not set it
* at all on dark themes so that the fallback value can be used on
* dark themes.
*
* On light themes, since '--is-dark' exists, the above rule will be
* interpolated as:
* "background-color: false"
*
* On dark themes, since '--is-dark' doesn't exist, the fallback value
* will be used ('red' in this case) and the rule will be interpolated as:
* "background-color: red"
*
* It's easier to understand and remember like this.
*/
...(!isDark ? {'--is-dark': 'false'} : {}),
/* Generate custom properties from customProps */
..._.fromPairs(_.flatten(_.map(background, (value, key) => [[`--fuse-${e(key)}`, value], [`--fuse-${e(key)}-rgb`, chroma(value).rgb().join(',')]]))),
..._.fromPairs(_.flatten(_.map(foreground, (value, key) => [[`--fuse-${e(key)}`, value], [`--fuse-${e(key)}-rgb`, chroma(value).rgb().join(',')]])))
}
};
});
const schemeUtilities = (() =>
{
/* Generate general styles & utilities */
return {};
})();
addComponents(schemeCustomProps);
addComponents(schemeUtilities);
},
(options) =>
{
return {
theme: {
extend: {
/**
* Add 'Primary', 'Accent' and 'Warn' palettes as colors so all color utilities
* are generated for them; "bg-primary", "text-on-primary", "bg-accent-600" etc.
* This will also allow using arbitrary values with them such as opacity and such.
*/
colors: _.fromPairs(_.flatten(_.map(_.keys(flattenColorPalette(normalizeTheme(options.themes.default))), (name) => [
[name, `rgba(var(--fuse-${name}-rgb), <alpha-value>)`],
[`on-${name}`, `rgba(var(--fuse-on-${name}-rgb), <alpha-value>)`]
])))
},
fuse : {
customProps: {
background: {
light: {
'bg-app-bar' : '#FFFFFF',
'bg-card' : '#FFFFFF',
'bg-default' : colors.slate[100],
'bg-dialog' : '#FFFFFF',
'bg-hover' : chroma(colors.slate[400]).alpha(0.12).css(),
'bg-status-bar': colors.slate[300]
},
dark : {
'bg-app-bar' : colors.slate[900],
'bg-card' : colors.slate[800],
'bg-default' : colors.slate[900],
'bg-dialog' : colors.slate[800],
'bg-hover' : 'rgba(255, 255, 255, 0.05)',
'bg-status-bar': colors.slate[900]
}
},
foreground: {
light: {
'text-default' : colors.slate[800],
'text-secondary': colors.slate[500],
'text-hint' : colors.slate[400],
'text-disabled' : colors.slate[400],
'border' : colors.slate[200],
'divider' : colors.slate[200],
'icon' : colors.slate[500],
'mat-icon' : colors.slate[500]
},
dark : {
'text-default' : '#FFFFFF',
'text-secondary': colors.slate[400],
'text-hint' : colors.slate[500],
'text-disabled' : colors.slate[600],
'border' : chroma(colors.slate[100]).alpha(0.12).css(),
'divider' : chroma(colors.slate[100]).alpha(0.12).css(),
'icon' : colors.slate[400],
'mat-icon' : colors.slate[400]
}
}
}
}
}
};
}
);
module.exports = theming;