From 9d78a9f8ebdbda48e8a338e36ca4a52e7d498ea0 Mon Sep 17 00:00:00 2001 From: Inesh Bose Date: Sun, 28 Apr 2024 10:06:28 +0100 Subject: [PATCH] fix: better check for unsafe config to fallback (#839) --- playground/nuxt.config.ts | 2 +- src/context.ts | 76 +++++++++++++++++++++++++++++++-------- src/module.ts | 25 +++---------- src/types.ts | 12 +++++-- 4 files changed, 78 insertions(+), 37 deletions(-) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index b881444f..077fb509 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -17,7 +17,7 @@ export default defineNuxtConfig({ ], tailwindcss: { // viewer: false, - config: { plugins: [typography()] }, + config: {}, exposeConfig: true, cssPath: '~/assets/css/tailwind.css', editorSupport: true, diff --git a/src/context.ts b/src/context.ts index 64adac18..4883fd07 100644 --- a/src/context.ts +++ b/src/context.ts @@ -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 = {} 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> => ({ + const trackObjChanges = (configPath: string, path: (string | symbol)[] = []): ProxyHandler> => ({ 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 @@ -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( @@ -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 = >(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))})` @@ -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() diff --git a/src/module.ts b/src/module.ts index 8cae8d6b..382f85ff 100644 --- a/src/module.ts +++ b/src/module.ts @@ -56,16 +56,6 @@ export default defineNuxtModule({ 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) => { @@ -113,8 +103,8 @@ export default defineNuxtModule({ 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) { @@ -134,7 +124,7 @@ export default defineNuxtModule({ 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 @@ -142,7 +132,7 @@ export default defineNuxtModule({ // 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({ @@ -163,12 +153,7 @@ export default defineNuxtModule({ 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) } } }) diff --git a/src/types.ts b/src/types.ts index 058789a3..9c01d8ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,15 @@ type Import = Exclude[0], an export type TWConfig = import('tailwindcss').Config export type InjectPosition = 'first' | 'last' | number | { after: string } -interface ExtendTailwindConfig { +type _Omit = { [P in keyof T as Exclude]: T[P] } + +type InlineTWConfig = _Omit & { + content?: (Extract | _Omit>, 'extract' | 'transform'>) + // plugins?: Extract[number], string | [string, any]>[] // incoming in Oxide + safelist?: Exclude[number], Record>[] +} + +type ExtendTWConfig = { content?: | TWConfig['content'] | ((contentDefaults: Array) => TWConfig['content']) @@ -84,7 +92,7 @@ export interface ModuleOptions { * * for default, see https://tailwindcss.nuxtjs.org/tailwind/config */ - config: Omit & ExtendTailwindConfig + config: InlineTWConfig /** * [tailwind-config-viewer](https://github.com/rogden/tailwind-config-viewer) usage *in development* *