Skip to content

Commit 09d7d9d

Browse files
authored
fix(preview-server): compatibility checker can't find config when inlining pixel based preset (#2351)
1 parent 5eb2257 commit 09d7d9d

File tree

5 files changed

+114
-64
lines changed

5 files changed

+114
-64
lines changed

.changeset/kind-experts-vanish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-email/preview-server": patch
3+
---
4+
5+
improved method of resolving tailwind configs when checking compatibility
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { promises as fs } from 'node:fs';
2+
import path from 'node:path';
3+
import { parse } from '@babel/parser';
4+
import { pixelBasedPreset } from '@react-email/components';
5+
import { getTailwindConfig } from './get-tailwind-config';
6+
7+
describe('getTailwindConfig()', () => {
8+
it("should work on the demo's Vercel Invite template", async () => {
9+
const sourcePath = path.resolve(
10+
__dirname,
11+
'../../../../../../apps/demo/emails/notifications/vercel-invite-user.tsx',
12+
);
13+
const sourceCode = await fs.readFile(sourcePath, 'utf8');
14+
const ast = parse(sourceCode, {
15+
strictMode: false,
16+
errorRecovery: true,
17+
sourceType: 'unambiguous',
18+
plugins: ['jsx', 'typescript', 'decorators'],
19+
});
20+
21+
expect(await getTailwindConfig(sourceCode, ast, sourcePath)).toEqual({
22+
presets: [pixelBasedPreset],
23+
});
24+
});
25+
26+
it('should work with email templates that import the tailwind config', async () => {
27+
const sourcePath = path.resolve(
28+
__dirname,
29+
'./tests/dummy-email-template.tsx',
30+
);
31+
const sourceCode = await fs.readFile(sourcePath, 'utf8');
32+
const ast = parse(sourceCode, {
33+
strictMode: false,
34+
errorRecovery: true,
35+
sourceType: 'unambiguous',
36+
plugins: ['jsx', 'typescript', 'decorators'],
37+
});
38+
39+
expect(await getTailwindConfig(sourceCode, ast, sourcePath)).toEqual({
40+
theme: {},
41+
presets: [pixelBasedPreset],
42+
});
43+
});
44+
});

packages/preview-server/src/utils/caniemail/tailwind/get-tailwind-config.ts

Lines changed: 49 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import path from 'node:path';
22
import type { Node } from '@babel/traverse';
33
import traverse from '@babel/traverse';
44
import * as esbuild from 'esbuild';
5+
import type { RawSourceMap } from 'source-map-js';
56
import type { Config as TailwindOriginalConfig } from 'tailwindcss';
67
import type { AST } from '../../../actions/email-validation/check-compatibility';
8+
import { improveErrorWithSourceMap } from '../../improve-error-with-sourcemap';
79
import { isErr } from '../../result';
810
import { runBundledCode } from '../../run-bundled-code';
911

@@ -23,8 +25,6 @@ export type TailwindConfig = Pick<
2325
| 'plugins'
2426
>;
2527

26-
type ImportDeclaration = Node & { type: 'ImportDeclaration' };
27-
2828
export const getTailwindConfig = async (
2929
sourceCode: string,
3030
ast: AST,
@@ -33,35 +33,23 @@ export const getTailwindConfig = async (
3333
const configAttribute = getTailwindConfigNode(ast);
3434

3535
if (configAttribute) {
36-
const configIdentifierName =
37-
configAttribute.value?.type === 'JSXExpressionContainer' &&
38-
configAttribute.value.expression.type === 'Identifier'
39-
? configAttribute.value.expression.name
40-
: undefined;
41-
if (configIdentifierName) {
42-
const tailwindConfigImport = getImportWithGivenDefaultSpecifier(
43-
ast,
44-
configIdentifierName,
45-
);
46-
if (tailwindConfigImport) {
47-
return getConfigFromImport(tailwindConfigImport, sourcePath);
48-
}
49-
}
50-
51-
const configObjectExpression =
52-
configAttribute.value?.type === 'JSXExpressionContainer' &&
53-
configAttribute.value.expression.type === 'ObjectExpression'
36+
const configExpressionValue =
37+
configAttribute.value?.type === 'JSXExpressionContainer'
5438
? configAttribute.value.expression
5539
: undefined;
56-
if (configObjectExpression?.start && configObjectExpression.end) {
57-
const configObjectSourceCode = sourceCode.slice(
58-
configObjectExpression.start,
59-
configObjectExpression.end,
40+
if (configExpressionValue?.start && configExpressionValue.end) {
41+
const configSourceValue = sourceCode.slice(
42+
configExpressionValue.start,
43+
configExpressionValue.end,
6044
);
6145

6246
try {
63-
const getConfig = new Function(`return ${configObjectSourceCode}`);
64-
return getConfig() as TailwindConfig;
47+
return getConfigFromCode(
48+
`${sourceCode}
49+
50+
const reactEmailTailwindConfigInternal = ${configSourceValue};`,
51+
sourcePath,
52+
);
6553
} catch (exception) {
6654
console.warn(exception);
6755
console.warn(
@@ -74,80 +62,77 @@ export const getTailwindConfig = async (
7462
return {};
7563
};
7664

77-
const getConfigFromImport = async (
78-
tailwindConfigImport: ImportDeclaration,
79-
sourcePath: string,
65+
const getConfigFromCode = async (
66+
code: string,
67+
filepath: string,
8068
): Promise<TailwindConfig> => {
81-
const configRelativePath = tailwindConfigImport.source.value;
82-
const sourceDirpath = path.dirname(sourcePath);
83-
const configFilepath = path.join(sourceDirpath, configRelativePath);
69+
const configDirpath = path.dirname(filepath);
8470

8571
const configBuildResult = await esbuild.build({
8672
bundle: true,
8773
stdin: {
88-
contents: `import tailwindConfig from "${configRelativePath}";
89-
export { tailwindConfig };`,
74+
contents: `${code}
75+
export { reactEmailTailwindConfigInternal };`,
76+
sourcefile: filepath,
9077
loader: 'tsx',
91-
resolveDir: path.dirname(sourcePath),
78+
resolveDir: configDirpath,
9279
},
9380
platform: 'node',
81+
sourcemap: 'external',
82+
jsx: 'automatic',
83+
outdir: 'stdout', // just a stub for esbuild, it won't actually write to this folder
9484
write: false,
9585
format: 'cjs',
9686
logLevel: 'silent',
9787
});
98-
const configFile = configBuildResult.outputFiles[0];
88+
const sourceMapFile = configBuildResult.outputFiles[0]!;
89+
const configFile = configBuildResult.outputFiles[1];
9990
if (configFile === undefined) {
10091
throw new Error(
10192
'Could not build config file as it was found as undefined, this is most likely a bug, please open an issue.',
10293
);
10394
}
104-
const configModule = runBundledCode(configFile.text, configFilepath);
95+
96+
const configModule = runBundledCode(configFile.text, filepath);
10597
if (isErr(configModule)) {
106-
throw new Error(
107-
`Error when trying to run the config file: ${configModule.error}`,
98+
const sourceMap = JSON.parse(sourceMapFile.text) as RawSourceMap;
99+
// because it will have a path like <tsconfigLocation>/stdout/email.js.map
100+
sourceMap.sourceRoot = path.resolve(sourceMapFile.path, '../..');
101+
sourceMap.sources = sourceMap.sources.map((source) =>
102+
path.resolve(sourceMapFile.path, '..', source),
108103
);
104+
const errorObject = improveErrorWithSourceMap(
105+
configModule.error as Error,
106+
filepath,
107+
sourceMap,
108+
);
109+
const error = new Error();
110+
error.name = errorObject.name;
111+
error.message = errorObject.message;
112+
error.stack = errorObject.stack;
113+
throw error;
109114
}
110115

111116
if (
112117
typeof configModule.value === 'object' &&
113118
configModule.value !== null &&
114-
'tailwindConfig' in configModule.value
119+
'reactEmailTailwindConfigInternal' in configModule.value
115120
) {
116-
return configModule.value.tailwindConfig as TailwindConfig;
121+
return configModule.value
122+
.reactEmailTailwindConfigInternal as TailwindConfig;
117123
}
118124

119125
throw new Error(
120-
`Could not read Tailwind config at ${configFilepath} because it doesn't have a default export in it.`,
126+
'Could not get the Tailwind config, this is likely a bug, please file an issue.',
121127
{
122128
cause: {
123129
configModule,
124-
configFilepath,
130+
configFilepath: filepath,
125131
},
126132
},
127133
);
128134
};
129135

130-
const getImportWithGivenDefaultSpecifier = (
131-
ast: AST,
132-
specifierName: string,
133-
) => {
134-
let importNode: ImportDeclaration | undefined;
135-
traverse(ast, {
136-
ImportDeclaration(nodePath) {
137-
if (
138-
nodePath.node.specifiers.some(
139-
(specifier) =>
140-
specifier.type === 'ImportDefaultSpecifier' &&
141-
specifier.local.name === specifierName,
142-
)
143-
) {
144-
importNode = nodePath.node;
145-
}
146-
},
147-
});
148-
return importNode;
149-
};
150-
151136
type JSXAttribute = Node & { type: 'JSXAttribute' };
152137

153138
const getTailwindConfigNode = (ast: AST) => {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Tailwind } from '@react-email/components';
2+
import tailwindConfig from './tailwind.config';
3+
4+
export default function EmailTemplate() {
5+
return (
6+
<Tailwind config={tailwindConfig}>
7+
<div className="bg-red-400 w-20" />
8+
</Tailwind>
9+
);
10+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { pixelBasedPreset, type TailwindConfig } from '@react-email/components';
2+
3+
export default {
4+
theme: {},
5+
presets: [pixelBasedPreset],
6+
} satisfies TailwindConfig;

0 commit comments

Comments
 (0)