Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: better check for unsafe config to fallback #839

Merged
merged 5 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default defineNuxtConfig({
],
tailwindcss: {
// viewer: false,
config: { plugins: [typography()] },
config: {},
exposeConfig: true,
cssPath: '~/assets/css/tailwind.css',
editorSupport: true,
Expand Down
76 changes: 62 additions & 14 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,31 +31,77 @@ twCtx.set = (instance, replace = true) => {
set(resolvedConfig as unknown as TWConfig, replace)
}

const unsafeInlineConfig = (inlineConfig: ModuleOptions['config']) => {
if (!inlineConfig) return

if (
'plugins' in inlineConfig && Array.isArray(inlineConfig.plugins)
&& inlineConfig.plugins.find(p => typeof p === 'function' || typeof p?.handler === 'function')
) {
return 'plugins'
}

if (inlineConfig.content) {
const invalidProperty = ['extract', 'transform'].find((i) => i in inlineConfig.content! && typeof inlineConfig.content![i as keyof ModuleOptions['config']['content']] === 'function' )

if (invalidProperty) {
return `content.${invalidProperty}`
}
}

if (inlineConfig.safelist) {
// @ts-expect-error `s` is never
const invalidIdx = inlineConfig.safelist.findIndex((s) => typeof s === 'object' && s.pattern instanceof RegExp)

if (invalidIdx > -1) {
return `safelist[${invalidIdx}]`
}
}
}

const JSONStringifyWithRegex = (obj: any) => JSON.stringify(obj, (_, v) => v instanceof RegExp ? `__REGEXP ${v.toString()}` : v)

const createInternalContext = async (moduleOptions: ModuleOptions, nuxt = useNuxt()) => {
const [configPaths, contentPaths] = await resolveModulePaths(moduleOptions.configPath, nuxt)
const configUpdatedHook: Record<string, string> = {}
const configResolvedPath = join(nuxt.options.buildDir, CONFIG_TEMPLATE_NAME)
let enableHMR = true

const unsafeProperty = unsafeInlineConfig(moduleOptions.config)
if (unsafeProperty) {
logger.warn(
`The provided Tailwind configuration in your \`nuxt.config\` is non-serializable. Check \`${unsafeProperty}\`. Falling back to providing the loaded configuration inlined directly to PostCSS loader..`,
'Please consider using `tailwind.config` or a separate file (specifying in `configPath` of the module options) to enable it with additional support for IntelliSense and HMR. Suppress this warning with `quiet: true` in the module options.',
)
enableHMR = false
}

const trackProxy = (configPath: string, path: (string | symbol)[] = []): ProxyHandler<Partial<TWConfig>> => ({
const trackObjChanges = (configPath: string, path: (string | symbol)[] = []): ProxyHandler<Partial<TWConfig>> => ({
get: (target, key: string) => {
return (typeof target[key] === 'object' && target[key] !== null)
? new Proxy(target[key], trackProxy(configPath, path.concat(key)))
? new Proxy(target[key], trackObjChanges(configPath, path.concat(key)))
: target[key]
},

set(target, key, value) {
const resultingCode = `cfg${path.concat(key).map(k => `[${JSON.stringify(k)}]`).join('')} = ${JSON.stringify(value)};`
const cfgKey = path.concat(key).map(k => `[${JSON.stringify(k)}]`).join('')
const resultingCode = `cfg${cfgKey} = ${JSONStringifyWithRegex(value)?.replace(/"__REGEXP (.*)"/g, (_, substr) => substr.replace(/\\"/g, '"')) || `cfg${cfgKey}`};`
const functionalStringify = (val: any) => JSON.stringify(val, (_, v) => ['function'].includes(typeof v) ? CONFIG_TEMPLATE_NAME + 'ns' : v)

if (JSON.stringify(target[key as string]) === JSON.stringify(value) || configUpdatedHook[configPath].endsWith(resultingCode)) {
if (functionalStringify(target[key as string]) === functionalStringify(value) || configUpdatedHook[configPath].endsWith(resultingCode)) {
return Reflect.set(target, key, value)
}

if (key === 'plugins' && typeof value === 'function') {
if (functionalStringify(value).includes(`"${CONFIG_TEMPLATE_NAME + 'ns'}"`)) {
logger.warn(
'You have injected a functional plugin into your Tailwind Config which cannot be serialized.',
'Please use a configuration file/template instead.',
`A hook has injected a non-serializable value in \`config${cfgKey}\`, so the Tailwind Config cannot be serialized. Falling back to providing the loaded configuration inlined directly to PostCSS loader..`,
'Please consider using a configuration file/template instead (specifying in `configPath` of the module options) to enable additional support for IntelliSense and HMR.',
)
// return false // removed for backwards compatibility
enableHMR = false
}

if (JSONStringifyWithRegex(value).includes('__REGEXP')) {
logger.warn(`A hook is injecting RegExp values in your configuration (check \`config${cfgKey}\`) which may be unsafely serialized. Consider moving your safelist to a separate configuration file/template instead (specifying in \`configPath\` of the module options)`)
}

configUpdatedHook[configPath] += resultingCode
Expand Down Expand Up @@ -93,7 +139,7 @@ const createInternalContext = async (moduleOptions: ModuleOptions, nuxt = useNux
configUpdatedHook[configPath] += 'cfg.content = cfg.purge;'
}

await nuxt.callHook('tailwindcss:loadConfig', _tailwindConfig && new Proxy(_tailwindConfig, trackProxy(configPath)), configPath, idx, paths)
await nuxt.callHook('tailwindcss:loadConfig', _tailwindConfig && new Proxy(_tailwindConfig, trackObjChanges(configPath)), configPath, idx, paths)
return _tailwindConfig || {}
})),
).then(configs => configs.reduce(
Expand All @@ -104,21 +150,21 @@ const createInternalContext = async (moduleOptions: ModuleOptions, nuxt = useNux

// Allow extending tailwindcss config by other modules
configUpdatedHook['main-config'] = ''
await nuxt.callHook('tailwindcss:config', new Proxy(tailwindConfig, trackProxy('main-config')))
await nuxt.callHook('tailwindcss:config', new Proxy(tailwindConfig, trackObjChanges('main-config')))
twCtx.set(tailwindConfig)

return tailwindConfig
}

const generateConfig = () => addTemplate({
const generateConfig = () => enableHMR ? addTemplate({
filename: CONFIG_TEMPLATE_NAME,
write: true,
getContents: () => {
const serializeConfig = <T extends Partial<TWConfig>>(config: T) =>
JSON.stringify(
Array.isArray(config.plugins) && config.plugins.length > 0 ? configMerger({ plugins: (defaultPlugins: TWConfig['plugins']) => defaultPlugins?.filter(p => p && typeof p !== 'function') }, config) : config,
(_, v) => typeof v === 'function' ? `() => (${JSON.stringify(v())})` : v).replace(/"(\(\) => \(.*\))"/g, (_, substr) => substr.replace(/\\"/g, '"'),
)
(_, v) => typeof v === 'function' ? `() => (${JSON.stringify(v())})` : v
).replace(/"(\(\) => \(.*\))"/g, (_, substr) => substr.replace(/\\"/g, '"'))

const layerConfigs = configPaths.map((configPath) => {
const configImport = `require(${JSON.stringify(/[/\\]node_modules[/\\]/.test(configPath) ? configPath : './' + relative(nuxt.options.buildDir, configPath))})`
Expand All @@ -135,9 +181,11 @@ const createInternalContext = async (moduleOptions: ModuleOptions, nuxt = useNux
`module.exports = ${configUpdatedHook['main-config'] ? `(() => {const cfg=config;${configUpdatedHook['main-config']};return cfg;})()` : 'config'}\n`,
].join('\n')
},
})
}) : { dst: '' }

const registerHooks = () => {
if (!enableHMR) return

nuxt.hook('app:templatesGenerated', async (_app, templates) => {
if (templates.some(t => configPaths.includes(t.dst))) {
await loadConfig()
Expand Down
25 changes: 5 additions & 20 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,6 @@ export default defineNuxtModule<ModuleOptions>({
if (moduleOptions.quiet) logger.level = LogLevels.silent
deprecationWarnings(moduleOptions, nuxt)

let enableHMR = true

if (Array.isArray(moduleOptions.config.plugins) && moduleOptions.config.plugins.find(p => typeof p === 'function' || typeof p?.handler === 'function')) {
logger.warn(
'You have provided functional plugins in `tailwindcss.config` in your Nuxt configuration that cannot be serialized for Tailwind Config.',
'Please use `tailwind.config` or a separate file (specifying in `tailwindcss.configPath`) to enable it with additional support for IntelliSense and HMR.',
)
enableHMR = false
}

// install postcss8 module on nuxt < 2.16
if (Number.parseFloat(getNuxtVersion()) < 2.16) {
await installModule('@nuxt/postcss8').catch((e) => {
Expand Down Expand Up @@ -113,8 +103,8 @@ export default defineNuxtModule<ModuleOptions>({
nuxt.hook('modules:done', async () => {
const _config = await ctx.loadConfig()

const twConfig = enableHMR ? ctx.generateConfig() : { dst: '' }
enableHMR && ctx.registerHooks()
const twConfig = ctx.generateConfig()
ctx.registerHooks()

// expose resolved tailwind config as an alias
if (moduleOptions.exposeConfig) {
Expand All @@ -134,15 +124,15 @@ export default defineNuxtModule<ModuleOptions>({
postcssOptions.plugins = {
...(postcssOptions.plugins || {}),
'tailwindcss/nesting': postcssOptions.plugins?.['tailwindcss/nesting'] ?? {},
'tailwindcss': enableHMR ? twConfig.dst satisfies string : _config,
'tailwindcss': twConfig.dst satisfies string || _config,
}

// enabled only in development
if (nuxt.options.dev) {
// add tailwind-config-viewer endpoint
if (moduleOptions.viewer) {
const viewerConfig = resolvers.resolveViewerConfig(moduleOptions.viewer)
setupViewer(enableHMR ? twConfig.dst : _config, viewerConfig, nuxt)
setupViewer(twConfig.dst || _config, viewerConfig, nuxt)

nuxt.hook('devtools:customTabs', (tabs: import('@nuxt/devtools').ModuleOptions['customTabs']) => {
tabs?.push({
Expand All @@ -163,12 +153,7 @@ export default defineNuxtModule<ModuleOptions>({
if (moduleOptions.viewer) {
const viewerConfig = resolvers.resolveViewerConfig(moduleOptions.viewer)

if (enableHMR) {
exportViewer(twConfig.dst, viewerConfig)
}
else {
exportViewer(addTemplate({ filename: 'tailwind.config/viewer-config.cjs', getContents: () => `module.exports = ${JSON.stringify(_config)}`, write: true }).dst, viewerConfig)
}
exportViewer(twConfig.dst || addTemplate({ filename: 'tailwind.config/viewer-config.cjs', getContents: () => `module.exports = ${JSON.stringify(_config)}`, write: true }).dst, viewerConfig)
}
}
})
Expand Down
12 changes: 10 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ type Import = Exclude<Parameters<typeof import('nuxt/kit')['addImports']>[0], an
export type TWConfig = import('tailwindcss').Config
export type InjectPosition = 'first' | 'last' | number | { after: string }

interface ExtendTailwindConfig {
type _Omit<T, K extends PropertyKey> = { [P in keyof T as Exclude<P, K>]: T[P] }

type InlineTWConfig = _Omit<TWConfig, 'content' | 'plugins' | 'safelist'> & {
content?: (Extract<TWConfig['content'], any[]> | _Omit<Extract<TWConfig['content'], Record<string, any>>, 'extract' | 'transform'>)
// plugins?: Extract<NonNullable<TWConfig['plugins']>[number], string | [string, any]>[] // incoming in Oxide
safelist?: Exclude<NonNullable<TWConfig['safelist']>[number], Record<string, any>>[]
}

type ExtendTWConfig = {
content?:
| TWConfig['content']
| ((contentDefaults: Array<string>) => TWConfig['content'])
Expand Down Expand Up @@ -84,7 +92,7 @@ export interface ModuleOptions {
*
* for default, see https://tailwindcss.nuxtjs.org/tailwind/config
*/
config: Omit<TWConfig, keyof ExtendTailwindConfig> & ExtendTailwindConfig
config: InlineTWConfig
/**
* [tailwind-config-viewer](https://github.com/rogden/tailwind-config-viewer) usage *in development*
*
Expand Down