mirror of
https://github.com/resend/react-email.git
synced 2026-09-14 14:18:02 +08:00
fix(preview-server): compatibility checker can't find config when inlining pixel based preset (#2351)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@react-email/preview-server": patch
|
||||
---
|
||||
|
||||
improved method of resolving tailwind configs when checking compatibility
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TailwindConfig> => {
|
||||
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 <tsconfigLocation>/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) => {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Tailwind } from '@react-email/components';
|
||||
import tailwindConfig from './tailwind.config';
|
||||
|
||||
export default function EmailTemplate() {
|
||||
return (
|
||||
<Tailwind config={tailwindConfig}>
|
||||
<div className="bg-red-400 w-20" />
|
||||
</Tailwind>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { pixelBasedPreset, type TailwindConfig } from '@react-email/components';
|
||||
|
||||
export default {
|
||||
theme: {},
|
||||
presets: [pixelBasedPreset],
|
||||
} satisfies TailwindConfig;
|
||||
Reference in New Issue
Block a user