feat(module): smart safelisting (#268)

Co-authored-by: Sébastien Chopin <seb@nuxtjs.com>
This commit is contained in:
Benjamin Canac
2023-06-13 12:18:38 +02:00
committed by GitHub
parent e12e9740c9
commit 20fa4d2317
11 changed files with 250 additions and 120 deletions

View File

@@ -70,7 +70,7 @@ watch(grayCookie, (gray) => {
const primaryOptions = computed(() => useWithout(appConfig.ui.colors, 'primary').map(color => ({ value: color, text: color, hex: colors[color][colorMode.value === 'dark' ? 400 : 500] })))
const primary = computed({
get () {
return primaryOptions.value.find(option => option.value === primaryCookie.value)
return primaryOptions.value.find(option => option.value === primaryCookie.value) || primaryOptions.value.find(option => option.value === 'green')
},
set (option) {
primaryCookie.value = option.value
@@ -80,7 +80,7 @@ const primary = computed({
const grayOptions = computed(() => ['slate', 'cool', 'zinc', 'neutral', 'stone'].map(color => ({ value: color, text: color, hex: colors[color][colorMode.value === 'dark' ? 400 : 500] })))
const gray = computed({
get () {
return grayOptions.value.find(option => option.value === grayCookie.value)
return grayOptions.value.find(option => option.value === grayCookie.value) || grayOptions.value.find(option => option.value === 'cool')
},
set (option) {
grayCookie.value = option.value

View File

@@ -45,6 +45,7 @@ As this module installs [@nuxtjs/tailwindcss](https://tailwindcss.nuxtjs.org/) a
| `prefix` | `u` | Define the prefix of the imported components. |
| `global` | `false` | Expose components globally. |
| `icons` | `['heroicons']` | Icon collections to load. |
| `safelistColors` | `['primary']` | Force safelisting of colors. |
## Edge

View File

@@ -33,13 +33,43 @@ Likewise, you can't define a `primary` color in your `tailwind.config.ts` as it
We'd advise you to use those colors in your components and pages, e.g. `text-primary-500 dark:text-primary-400`, `bg-gray-100 dark:bg-gray-900`, etc. so your app automatically adapts when changing your `app.config.ts`.
::
Components that have a `color` prop like [Avatar](/elements/avatar#chip), [Badge](/elements/badge#style), [Button](/elements/button#style) and [Notification](/overlays/notification#timeout) will use the `primary` color by default but will handle all the colors defined in your `tailwind.config.ts` or the default Tailwind CSS colors.
Components having a `color` prop like [Avatar](/elements/avatar#chip), [Badge](/elements/badge#style), [Button](/elements/button#style), [Input](/elements/input#style) (inherited in [Select](/forms/select) and [SelectMenu](/forms/select-menu)) and [Notification](/overlays/notification#timeout) will use the `primary` color by default but will handle all the colors defined in your `tailwind.config.ts` or the default Tailwind CSS colors.
Variant classes of those components are defined with a syntax like `bg-{color}-500 dark:bg-{color}-400` so they can be used with any color. However, this means that Tailwind will not find those classes and therefore will not generate the corresponding CSS.
The module uses the [Tailwind CSS safelist](https://tailwindcss.com/docs/content-configuration#safelisting-classes) feature to force the generation of all the classes for the `primary` color **only** as it is the default color for all the components.
Then, the module will automatically detect when you use one of those components with a color and will safelist it for you. This means that if you use a `red` color for a Button component, the `red` color classes will be safelisted for the Button component only. This will allow to keep the CSS bundle size as small as possible.
There is one case where you would want to force the safelisting of a color. For example, if you've set the default color of the Button component to `orange` in your `app.config.ts`.
```ts [app.config.ts]
export default defineAppConfig({
ui: {
button: {
default: {
color: 'orange'
}
}
}
})
```
This will apply the orange color when using a default `<UButton />`. You'll need to safelist this color manually in your `nuxt.config.ts` ui options as we won't be able to detect it automatically. You can do so through the `safelistColors` option.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
ui: {
safelistColors: ['orange']
}
})
```
## Dark mode
All the components are styled with dark mode in mind.
Thanks to [Tailwind CSS dark mode](https://tailwindcss.com/docs/dark-mode#toggling-dark-mode-manually) `class` strategy and the [@nuxtjs/color-mode](https://github.com/nuxt-modules/color-mode) module, you literally have nothing to do.
Thanks to [Tailwind CSS dark mode](https://tailwindcss.com/docs/dark-mode#toggling-dark-mode-manually) class strategy and the [@nuxtjs/color-mode](https://github.com/nuxt-modules/color-mode) module, you literally have nothing to do.
## Components

View File

@@ -1,4 +1,5 @@
import ui from '../src/module'
import colors from 'tailwindcss/colors'
export default defineNuxtConfig({
// @ts-ignore
@@ -25,7 +26,8 @@ export default defineNuxtConfig({
},
ui: {
global: true,
icons: ['heroicons', 'simple-icons']
icons: ['heroicons', 'simple-icons'],
safelistColors: Object.keys(colors)
},
typescript: {
strict: false,

151
src/colors.ts Normal file
View File

@@ -0,0 +1,151 @@
const colorsToExclude = [
'inherit',
'transparent',
'current',
'white',
'black',
'slate',
'gray',
'zinc',
'neutral',
'stone',
'cool'
]
const omit = (obj: object, keys: string[]) => {
return Object.fromEntries(
Object.entries(obj).filter(([key]) => !keys.includes(key))
)
}
const kebabCase = (str: string) => {
return str
?.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
?.map(x => x.toLowerCase())
?.join('-')
}
const safelistByComponent = {
avatar: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex}|gray)-500`)
}, {
pattern: new RegExp(`bg-(${colorsAsRegex}|gray)-400`),
variants: ['dark']
}],
badge: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-50`)
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark']
}],
button: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-50`),
variants: ['hover']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-100`),
variants: ['hover']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark', 'dark:disabled']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`),
variants: ['disabled', 'dark:hover']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-600`),
variants: ['hover']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-900`),
variants: ['dark:hover']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-950`),
variants: ['dark', 'dark:hover']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`),
variants: ['dark:hover']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-600`),
variants: ['hover']
}, {
pattern: new RegExp(`outline-(${colorsAsRegex})-400`),
variants: ['dark:focus-visible']
}, {
pattern: new RegExp(`outline-(${colorsAsRegex})-500`),
variants: ['focus-visible']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark:focus-visible']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
variants: ['focus-visible']
}],
input: (colorsAsRegex) => [{
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark', 'dark:focus']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
variants: ['focus']
}],
notification: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex}|gray)-500`)
}, {
pattern: new RegExp(`bg-(${colorsAsRegex}|gray)-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex}|gray)-500`)
}, {
pattern: new RegExp(`text-(${colorsAsRegex}|gray)-400`),
variants: ['dark']
}]
}
const colorsAsRegex = (colors: string[]): string => colors.join('|')
export const excludeColors = (colors: object) => Object.keys(omit(colors, colorsToExclude)).map(color => kebabCase(color)) as string[]
export const generateSafelist = (colors: string[]) => ['avatar', 'badge', 'button', 'input', 'notification'].flatMap(component => safelistByComponent[component](colorsAsRegex(colors)))
export const customSafelistExtractor = (prefix, content: string) => {
const classes = []
const regex = /<(\w+)\s+[^>]*color=["']([^"']+)["'][^>]*>/gs
const matches = [...content.matchAll(regex)]
for (const match of matches) {
const [, component, color] = match
if (colorsToExclude.includes(color)) {
continue
}
if (Object.keys(safelistByComponent).map(component => `${prefix}${component.charAt(0).toUpperCase() + component.slice(1)}`).includes(component)) {
const name = component.replace(prefix, '').toLowerCase()
const matchClasses = safelistByComponent[name](color).flatMap(group => {
return ['', ...(group.variants || [])].flatMap(variant => {
const matches = group.pattern.source.match(/\(([^)]+)\)/g)
return matches.map(match => {
const colorOptions = match.substring(1, match.length - 1).split('|')
return colorOptions.map(color => `${variant ? variant + ':' : ''}` + group.pattern.source.replace(match, color))
}).flat()
})
})
classes.push(...matchClasses)
}
}
return classes
}

View File

@@ -1,22 +1,20 @@
import { defineNuxtModule, installModule, addComponentsDir, addImportsDir, createResolver, addPlugin, resolvePath } from '@nuxt/kit'
import colors from 'tailwindcss/colors.js'
import defaultColors from 'tailwindcss/colors.js'
import { defaultExtractor as createDefaultExtractor } from 'tailwindcss/lib/lib/defaultExtractor.js'
import { iconsPlugin, getIconCollections } from '@egoist/tailwindcss-icons'
import { name, version } from '../package.json'
import { colorsAsRegex, excludeColors } from './runtime/utils/colors'
import { generateSafelist, excludeColors, customSafelistExtractor } from './colors'
import appConfig from './runtime/app.config'
type DeepPartial<T> = Partial<{ [P in keyof T]: DeepPartial<T[P]> | { [key: string]: string } }>
// @ts-ignore
delete colors.lightBlue
// @ts-ignore
delete colors.warmGray
// @ts-ignore
delete colors.trueGray
// @ts-ignore
delete colors.coolGray
// @ts-ignore
delete colors.blueGray
const defaultExtractor = createDefaultExtractor({ tailwindConfig: { separator: ':' } })
delete defaultColors.lightBlue
delete defaultColors.warmGray
delete defaultColors.trueGray
delete defaultColors.coolGray
delete defaultColors.blueGray
declare module 'nuxt/schema' {
interface AppConfigInput {
@@ -40,6 +38,8 @@ export interface ModuleOptions {
global?: boolean
icons: string[] | string
safelistColors?: string[]
}
export default defineNuxtModule<ModuleOptions>({
@@ -52,8 +52,9 @@ export default defineNuxtModule<ModuleOptions>({
}
},
defaults: {
prefix: 'u',
icons: ['heroicons']
prefix: 'U',
icons: ['heroicons'],
safelistColors: ['primary']
},
async setup (options, nuxt) {
const { resolve } = createResolver(import.meta.url)
@@ -70,14 +71,14 @@ export default defineNuxtModule<ModuleOptions>({
app.configs.push(appConfigFile)
})
// @ts-ignore
nuxt.hook('tailwindcss:config', function (tailwindConfig: TailwindConfig) {
const globalColors = {
...(tailwindConfig.theme.colors || colors),
nuxt.hook('tailwindcss:config', function (tailwindConfig) {
const globalColors: any = {
...(tailwindConfig.theme.colors || defaultColors),
...tailwindConfig.theme.extend?.colors
}
tailwindConfig.theme.extend.colors = tailwindConfig.theme.extend.colors || {}
// @ts-ignore
globalColors.primary = tailwindConfig.theme.extend.colors.primary = {
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
100: 'rgb(var(--color-primary-100) / <alpha-value>)',
@@ -93,9 +94,11 @@ export default defineNuxtModule<ModuleOptions>({
}
if (globalColors.gray) {
globalColors.cool = tailwindConfig.theme.extend.colors.cool = colors.gray
// @ts-ignore
globalColors.cool = tailwindConfig.theme.extend.colors.cool = defaultColors.gray
}
// @ts-ignore
globalColors.gray = tailwindConfig.theme.extend.colors.gray = {
50: 'rgb(var(--color-gray-50) / <alpha-value>)',
100: 'rgb(var(--color-gray-100) / <alpha-value>)',
@@ -110,65 +113,24 @@ export default defineNuxtModule<ModuleOptions>({
950: 'rgb(var(--color-gray-950) / <alpha-value>)'
}
const variantColors = excludeColors(globalColors)
const safeColorsAsRegex = colorsAsRegex(variantColors)
const colors = excludeColors(globalColors)
nuxt.options.appConfig.ui = {
...nuxt.options.appConfig.ui,
primary: 'green',
gray: 'cool',
colors: variantColors
colors
}
tailwindConfig.safelist = tailwindConfig.safelist || []
tailwindConfig.safelist.push(...[
'bg-gray-500',
'dark:bg-gray-400',
{
pattern: new RegExp(`bg-(${safeColorsAsRegex})-(50|400|500)`)
}, {
pattern: new RegExp(`bg-(${safeColorsAsRegex})-500`),
variants: ['disabled']
}, {
pattern: new RegExp(`bg-(${safeColorsAsRegex})-(400|950)`),
variants: ['dark']
}, {
pattern: new RegExp(`bg-(${safeColorsAsRegex})-(500|900|950)`),
variants: ['dark:hover']
}, {
pattern: new RegExp(`bg-(${safeColorsAsRegex})-400`),
variants: ['dark:disabled']
}, {
pattern: new RegExp(`bg-(${safeColorsAsRegex})-(50|100|600)`),
variants: ['hover']
}, {
pattern: new RegExp(`outline-(${safeColorsAsRegex})-500`),
variants: ['focus-visible']
}, {
pattern: new RegExp(`outline-(${safeColorsAsRegex})-400`),
variants: ['dark:focus-visible']
}, {
pattern: new RegExp(`ring-(${safeColorsAsRegex})-500`),
variants: ['focus', 'focus-visible']
}, {
pattern: new RegExp(`ring-(${safeColorsAsRegex})-400`),
variants: ['dark', 'dark:focus', 'dark:focus-visible']
}, {
pattern: new RegExp(`text-(${safeColorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${safeColorsAsRegex})-500`),
variants: ['dark:hover']
}, {
pattern: new RegExp(`text-(${safeColorsAsRegex})-600`),
variants: ['hover']
}
])
tailwindConfig.safelist.push(...generateSafelist(options.safelistColors))
tailwindConfig.plugins = tailwindConfig.plugins || []
tailwindConfig.plugins.push(iconsPlugin({ collections: getIconCollections(options.icons as any[]) }))
})
// Modules
await installModule('@nuxtjs/color-mode', { classSuffix: '' })
await installModule('@nuxtjs/tailwindcss', {
viewer: false,
@@ -181,17 +143,31 @@ export default defineNuxtModule<ModuleOptions>({
require('@tailwindcss/typography'),
require('@tailwindcss/container-queries')
],
content: [
resolve(runtimeDir, 'components/**/*.{vue,mjs,ts}'),
resolve(runtimeDir, '*.{mjs,js,ts}')
]
content: {
files: [
resolve(runtimeDir, 'components/**/*.{vue,mjs,ts}'),
resolve(runtimeDir, '*.{mjs,js,ts}')
],
extract: {
vue: (content) => {
return [
...defaultExtractor(content),
...customSafelistExtractor(options.prefix, content)
]
}
}
}
}
})
// Plugins
addPlugin({
src: resolve(runtimeDir, 'plugins', 'colors')
})
// Components
addComponentsDir({
path: resolve(runtimeDir, 'components', 'elements'),
prefix: options.prefix,
@@ -229,6 +205,8 @@ export default defineNuxtModule<ModuleOptions>({
watch: false
})
// Composables
addImportsDir(resolve(runtimeDir, 'composables'))
}
})

View File

@@ -50,11 +50,11 @@ import type { PropType } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { defineComponent, ref, computed, onMounted } from 'vue'
import { defu } from 'defu'
import { omit } from 'lodash-es'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import UKbd from '../elements/Kbd.vue'
import ULinkCustom from '../elements/LinkCustom.vue'
import { omit } from '../../utils'
import { usePopper } from '../../composables/usePopper'
import type { Avatar } from '../../types/avatar'
import type { PopperOptions } from '../../types'

View File

@@ -42,10 +42,10 @@ import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { defu } from 'defu'
import { omit } from 'lodash-es'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import ULinkCustom from '../elements/LinkCustom.vue'
import { omit } from '../../utils'
import type { Avatar } from '../../types/avatar'
import { useAppConfig } from '#imports'
// TODO: Remove

View File

@@ -1,5 +1,5 @@
import { computed } from 'vue'
import { hexToRgb } from '../utils/colors'
import { hexToRgb } from '../utils'
import { defineNuxtPlugin, useHead, useAppConfig, useNuxtApp } from '#imports'
import colors from '#tailwind-config/theme/colors'

View File

@@ -1,32 +0,0 @@
import { omit, kebabCase } from './index'
export const colorsToExclude = [
'inherit',
'transparent',
'current',
'white',
'black',
'slate',
'gray',
'zinc',
'neutral',
'stone',
'cool'
]
export const excludeColors = (colors: object) => Object.keys(omit(colors, colorsToExclude)).map(color => kebabCase(color)) as string[]
export const colorsAsRegex = (colors: string[]): string => colors.join('|')
export const hexToRgb = (hex) => {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
hex = hex.replace(shorthandRegex, function (_, r, g, b) {
return r + r + g + g + b + b
})
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}`
: null
}

View File

@@ -2,17 +2,17 @@ export function classNames (...classes: any[string]) {
return classes.filter(Boolean).join(' ')
}
export const kebabCase = (str: string) => {
return str
?.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
?.map(x => x.toLowerCase())
?.join('-')
}
export const hexToRgb = (hex) => {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
hex = hex.replace(shorthandRegex, function (_, r, g, b) {
return r + r + g + g + b + b
})
export const omit = (obj: object, keys: string[]) => {
return Object.fromEntries(
Object.entries(obj).filter(([key]) => !keys.includes(key))
)
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}`
: null
}
export const getSlotsChildren = (slots: any) => {