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

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) => {