diff --git a/.changeset/kind-experts-vanish.md b/.changeset/kind-experts-vanish.md new file mode 100644 index 000000000..745803b06 --- /dev/null +++ b/.changeset/kind-experts-vanish.md @@ -0,0 +1,5 @@ +--- +"@react-email/preview-server": patch +--- + +improved method of resolving tailwind configs when checking compatibility diff --git a/packages/preview-server/src/utils/caniemail/tailwind/get-tailwind-config.spec.ts b/packages/preview-server/src/utils/caniemail/tailwind/get-tailwind-config.spec.ts new file mode 100644 index 000000000..f5cb00f5a --- /dev/null +++ b/packages/preview-server/src/utils/caniemail/tailwind/get-tailwind-config.spec.ts @@ -0,0 +1,44 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { parse } from '@babel/parser'; +import { pixelBasedPreset } from '@react-email/components'; +import { getTailwindConfig } from './get-tailwind-config'; + +describe('getTailwindConfig()', () => { + it("should work on the demo's Vercel Invite template", async () => { + const sourcePath = path.resolve( + __dirname, + '../../../../../../apps/demo/emails/notifications/vercel-invite-user.tsx', + ); + const sourceCode = await fs.readFile(sourcePath, 'utf8'); + const ast = parse(sourceCode, { + strictMode: false, + errorRecovery: true, + sourceType: 'unambiguous', + plugins: ['jsx', 'typescript', 'decorators'], + }); + + expect(await getTailwindConfig(sourceCode, ast, sourcePath)).toEqual({ + presets: [pixelBasedPreset], + }); + }); + + it('should work with email templates that import the tailwind config', async () => { + const sourcePath = path.resolve( + __dirname, + './tests/dummy-email-template.tsx', + ); + const sourceCode = await fs.readFile(sourcePath, 'utf8'); + const ast = parse(sourceCode, { + strictMode: false, + errorRecovery: true, + sourceType: 'unambiguous', + plugins: ['jsx', 'typescript', 'decorators'], + }); + + expect(await getTailwindConfig(sourceCode, ast, sourcePath)).toEqual({ + theme: {}, + presets: [pixelBasedPreset], + }); + }); +}); diff --git a/packages/preview-server/src/utils/caniemail/tailwind/get-tailwind-config.ts b/packages/preview-server/src/utils/caniemail/tailwind/get-tailwind-config.ts index 8e65f7260..b4a2a1521 100644 --- a/packages/preview-server/src/utils/caniemail/tailwind/get-tailwind-config.ts +++ b/packages/preview-server/src/utils/caniemail/tailwind/get-tailwind-config.ts @@ -2,8 +2,10 @@ import path from 'node:path'; import type { Node } from '@babel/traverse'; import traverse from '@babel/traverse'; import * as esbuild from 'esbuild'; +import type { RawSourceMap } from 'source-map-js'; import type { Config as TailwindOriginalConfig } from 'tailwindcss'; import type { AST } from '../../../actions/email-validation/check-compatibility'; +import { improveErrorWithSourceMap } from '../../improve-error-with-sourcemap'; import { isErr } from '../../result'; import { runBundledCode } from '../../run-bundled-code'; @@ -23,8 +25,6 @@ export type TailwindConfig = Pick< | 'plugins' >; -type ImportDeclaration = Node & { type: 'ImportDeclaration' }; - export const getTailwindConfig = async ( sourceCode: string, ast: AST, @@ -33,35 +33,23 @@ export const getTailwindConfig = async ( const configAttribute = getTailwindConfigNode(ast); if (configAttribute) { - const configIdentifierName = - configAttribute.value?.type === 'JSXExpressionContainer' && - configAttribute.value.expression.type === 'Identifier' - ? configAttribute.value.expression.name - : undefined; - if (configIdentifierName) { - const tailwindConfigImport = getImportWithGivenDefaultSpecifier( - ast, - configIdentifierName, - ); - if (tailwindConfigImport) { - return getConfigFromImport(tailwindConfigImport, sourcePath); - } - } - - const configObjectExpression = - configAttribute.value?.type === 'JSXExpressionContainer' && - configAttribute.value.expression.type === 'ObjectExpression' + const configExpressionValue = + configAttribute.value?.type === 'JSXExpressionContainer' ? configAttribute.value.expression : undefined; - if (configObjectExpression?.start && configObjectExpression.end) { - const configObjectSourceCode = sourceCode.slice( - configObjectExpression.start, - configObjectExpression.end, + if (configExpressionValue?.start && configExpressionValue.end) { + const configSourceValue = sourceCode.slice( + configExpressionValue.start, + configExpressionValue.end, ); try { - const getConfig = new Function(`return ${configObjectSourceCode}`); - return getConfig() as TailwindConfig; + return getConfigFromCode( + `${sourceCode} + +const reactEmailTailwindConfigInternal = ${configSourceValue};`, + sourcePath, + ); } catch (exception) { console.warn(exception); console.warn( @@ -74,80 +62,77 @@ export const getTailwindConfig = async ( return {}; }; -const getConfigFromImport = async ( - tailwindConfigImport: ImportDeclaration, - sourcePath: string, +const getConfigFromCode = async ( + code: string, + filepath: string, ): Promise => { - const configRelativePath = tailwindConfigImport.source.value; - const sourceDirpath = path.dirname(sourcePath); - const configFilepath = path.join(sourceDirpath, configRelativePath); + const configDirpath = path.dirname(filepath); const configBuildResult = await esbuild.build({ bundle: true, stdin: { - contents: `import tailwindConfig from "${configRelativePath}"; -export { tailwindConfig };`, + contents: `${code} +export { reactEmailTailwindConfigInternal };`, + sourcefile: filepath, loader: 'tsx', - resolveDir: path.dirname(sourcePath), + resolveDir: configDirpath, }, platform: 'node', + sourcemap: 'external', + jsx: 'automatic', + outdir: 'stdout', // just a stub for esbuild, it won't actually write to this folder write: false, format: 'cjs', logLevel: 'silent', }); - const configFile = configBuildResult.outputFiles[0]; + const sourceMapFile = configBuildResult.outputFiles[0]!; + const configFile = configBuildResult.outputFiles[1]; if (configFile === undefined) { throw new Error( 'Could not build config file as it was found as undefined, this is most likely a bug, please open an issue.', ); } - const configModule = runBundledCode(configFile.text, configFilepath); + + const configModule = runBundledCode(configFile.text, filepath); if (isErr(configModule)) { - throw new Error( - `Error when trying to run the config file: ${configModule.error}`, + const sourceMap = JSON.parse(sourceMapFile.text) as RawSourceMap; + // because it will have a path like /stdout/email.js.map + sourceMap.sourceRoot = path.resolve(sourceMapFile.path, '../..'); + sourceMap.sources = sourceMap.sources.map((source) => + path.resolve(sourceMapFile.path, '..', source), ); + const errorObject = improveErrorWithSourceMap( + configModule.error as Error, + filepath, + sourceMap, + ); + const error = new Error(); + error.name = errorObject.name; + error.message = errorObject.message; + error.stack = errorObject.stack; + throw error; } if ( typeof configModule.value === 'object' && configModule.value !== null && - 'tailwindConfig' in configModule.value + 'reactEmailTailwindConfigInternal' in configModule.value ) { - return configModule.value.tailwindConfig as TailwindConfig; + return configModule.value + .reactEmailTailwindConfigInternal as TailwindConfig; } throw new Error( - `Could not read Tailwind config at ${configFilepath} because it doesn't have a default export in it.`, + 'Could not get the Tailwind config, this is likely a bug, please file an issue.', { cause: { configModule, - configFilepath, + configFilepath: filepath, }, }, ); }; -const getImportWithGivenDefaultSpecifier = ( - ast: AST, - specifierName: string, -) => { - let importNode: ImportDeclaration | undefined; - traverse(ast, { - ImportDeclaration(nodePath) { - if ( - nodePath.node.specifiers.some( - (specifier) => - specifier.type === 'ImportDefaultSpecifier' && - specifier.local.name === specifierName, - ) - ) { - importNode = nodePath.node; - } - }, - }); - return importNode; -}; - type JSXAttribute = Node & { type: 'JSXAttribute' }; const getTailwindConfigNode = (ast: AST) => { diff --git a/packages/preview-server/src/utils/caniemail/tailwind/tests/dummy-email-template.tsx b/packages/preview-server/src/utils/caniemail/tailwind/tests/dummy-email-template.tsx new file mode 100644 index 000000000..ea5e1b2c8 --- /dev/null +++ b/packages/preview-server/src/utils/caniemail/tailwind/tests/dummy-email-template.tsx @@ -0,0 +1,10 @@ +import { Tailwind } from '@react-email/components'; +import tailwindConfig from './tailwind.config'; + +export default function EmailTemplate() { + return ( + +
+ + ); +} diff --git a/packages/preview-server/src/utils/caniemail/tailwind/tests/tailwind.config.ts b/packages/preview-server/src/utils/caniemail/tailwind/tests/tailwind.config.ts new file mode 100644 index 000000000..0036ca0ce --- /dev/null +++ b/packages/preview-server/src/utils/caniemail/tailwind/tests/tailwind.config.ts @@ -0,0 +1,6 @@ +import { pixelBasedPreset, type TailwindConfig } from '@react-email/components'; + +export default { + theme: {}, + presets: [pixelBasedPreset], +} satisfies TailwindConfig;