react-email-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/config.mjs +19 -0
- package/bin/dev.mjs +63 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +103 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +98 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +76 -0
- package/src/index.ts +153 -0
- package/src/runtime.ts +157 -0
- package/src/version.ts +2 -0
package/bin/config.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfigFromFile } from "vite"
|
|
3
|
+
|
|
4
|
+
const loaded = await loadConfigFromFile({ command: "serve", mode: process.env.NODE_ENV ?? "development" })
|
|
5
|
+
const plugins = (loaded?.config?.plugins ?? []).flat(Infinity).filter(Boolean)
|
|
6
|
+
const plugin = plugins.find((plugin) => plugin.name === "react-email-rails")
|
|
7
|
+
const metadata = plugin?.[Symbol.for("react-email-rails.config")]
|
|
8
|
+
|
|
9
|
+
if (!plugin) {
|
|
10
|
+
process.stderr.write("react-email-rails: reactEmailRails() plugin not found in the Vite config\n")
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!metadata) {
|
|
15
|
+
process.stderr.write("react-email-rails: reactEmailRails() plugin metadata not found\n")
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
process.stdout.write(JSON.stringify(metadata))
|
package/bin/dev.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer, isRunnableDevEnvironment, loadConfigFromFile } from "vite"
|
|
3
|
+
import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
|
|
4
|
+
|
|
5
|
+
if (process.argv.includes("--health")) {
|
|
6
|
+
process.stdout.write(JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }))
|
|
7
|
+
process.exit(0)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const toStderr = (message) => process.stderr.write(`${message}\n`)
|
|
11
|
+
const logger = {
|
|
12
|
+
info: toStderr,
|
|
13
|
+
warn: toStderr,
|
|
14
|
+
warnOnce: toStderr,
|
|
15
|
+
error: toStderr,
|
|
16
|
+
clearScreen() {},
|
|
17
|
+
hasErrorLogged: () => false,
|
|
18
|
+
hasWarned: false,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Load only this plugin and aliases; host dev-server plugins have global side effects.
|
|
22
|
+
const loaded = await loadConfigFromFile({ command: "serve", mode: "development" })
|
|
23
|
+
const userConfig = loaded?.config ?? {}
|
|
24
|
+
const emailPlugin = (userConfig.plugins ?? [])
|
|
25
|
+
.flat(Infinity)
|
|
26
|
+
.find((plugin) => plugin?.name === "react-email-rails")
|
|
27
|
+
|
|
28
|
+
if (!emailPlugin) {
|
|
29
|
+
process.stderr.write("react-email-rails: reactEmailRails() plugin not found in the Vite config\n")
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Forward config that affects how components resolve and compile (but not the
|
|
34
|
+
// host's dev-server plugins, which have global side effects), so dev rendering
|
|
35
|
+
// stays close to the production email bundle.
|
|
36
|
+
const server = await createServer({
|
|
37
|
+
configFile: false,
|
|
38
|
+
resolve: userConfig.resolve,
|
|
39
|
+
define: userConfig.define,
|
|
40
|
+
css: userConfig.css,
|
|
41
|
+
esbuild: { jsx: "automatic" },
|
|
42
|
+
plugins: [emailPlugin],
|
|
43
|
+
server: { middlewareMode: true },
|
|
44
|
+
appType: "custom",
|
|
45
|
+
clearScreen: false,
|
|
46
|
+
customLogger: logger,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Render through the same `email` environment the production build uses, so dev
|
|
50
|
+
// and build resolve and compile components identically.
|
|
51
|
+
const environment = server.environments.email
|
|
52
|
+
if (!isRunnableDevEnvironment(environment)) {
|
|
53
|
+
await server.close()
|
|
54
|
+
process.stderr.write("react-email-rails: the email environment is not runnable\n")
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const { run } = await environment.runner.import("virtual:react-email-rails/server")
|
|
60
|
+
await run()
|
|
61
|
+
} finally {
|
|
62
|
+
await server.close()
|
|
63
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
export type EmailsOption = string | {
|
|
3
|
+
path?: string;
|
|
4
|
+
extension?: string | string[];
|
|
5
|
+
ignore?: string | string[];
|
|
6
|
+
};
|
|
7
|
+
export type ReactEmailRailsOptions = {
|
|
8
|
+
emails?: EmailsOption;
|
|
9
|
+
standalone?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare const EMAIL_ENVIRONMENT = "email";
|
|
12
|
+
export declare function reactEmailRails(options?: ReactEmailRailsOptions): Plugin;
|
|
13
|
+
export type { EmailModule, EmailRegistry, EmailRenderOptions, RenderedEmail, RenderRequest, } from "./runtime.js";
|
|
14
|
+
export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const DEFAULT_IGNORE = ["**/_*", "**/_*/**"];
|
|
2
|
+
const DEFAULT_EXTENSIONS = [".tsx", ".jsx"];
|
|
3
|
+
const VIRTUAL_SERVER = "virtual:react-email-rails/server";
|
|
4
|
+
const VIRTUAL_MAIN = "virtual:react-email-rails/main";
|
|
5
|
+
const RESOLVED_SERVER = `\0${VIRTUAL_SERVER}`;
|
|
6
|
+
const RESOLVED_MAIN = `\0${VIRTUAL_MAIN}`;
|
|
7
|
+
// The dedicated build environment that emits the server-side email bundle.
|
|
8
|
+
export const EMAIL_ENVIRONMENT = "email";
|
|
9
|
+
const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
|
|
10
|
+
const OUT_DIR = "tmp/react-email-rails";
|
|
11
|
+
const BUNDLE_FILE = "emails.js";
|
|
12
|
+
export function reactEmailRails(options = {}) {
|
|
13
|
+
const emails = typeof options.emails === "string" ? { path: options.emails } : (options.emails ?? {});
|
|
14
|
+
const path = (emails.path ?? "app/javascript/emails").replace(/^\/|\/$/g, "");
|
|
15
|
+
const rawExtensions = emails.extension === undefined
|
|
16
|
+
? DEFAULT_EXTENSIONS
|
|
17
|
+
: Array.isArray(emails.extension)
|
|
18
|
+
? emails.extension
|
|
19
|
+
: [emails.extension];
|
|
20
|
+
const extensions = rawExtensions
|
|
21
|
+
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
|
|
22
|
+
.map((extension, index) => ({ extension, index }))
|
|
23
|
+
.sort((left, right) => right.extension.length - left.extension.length || left.index - right.index)
|
|
24
|
+
.map(({ extension }) => extension);
|
|
25
|
+
const standalone = options.standalone ?? true;
|
|
26
|
+
const root = `/${path}/`;
|
|
27
|
+
const pattern = extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`;
|
|
28
|
+
const ignore = emails.ignore === undefined
|
|
29
|
+
? DEFAULT_IGNORE
|
|
30
|
+
: Array.isArray(emails.ignore)
|
|
31
|
+
? emails.ignore
|
|
32
|
+
: [emails.ignore];
|
|
33
|
+
const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)];
|
|
34
|
+
const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns);
|
|
35
|
+
const plugin = {
|
|
36
|
+
name: "react-email-rails",
|
|
37
|
+
resolveId(id) {
|
|
38
|
+
if (id === VIRTUAL_SERVER)
|
|
39
|
+
return RESOLVED_SERVER;
|
|
40
|
+
if (id === VIRTUAL_MAIN)
|
|
41
|
+
return RESOLVED_MAIN;
|
|
42
|
+
},
|
|
43
|
+
load(id) {
|
|
44
|
+
if (id === RESOLVED_SERVER) {
|
|
45
|
+
return [
|
|
46
|
+
`import { serve, toComponentName } from "react-email-rails/runtime"`,
|
|
47
|
+
`const modules = import.meta.glob(${globArg})`,
|
|
48
|
+
`const extensions = ${JSON.stringify(extensions)}`,
|
|
49
|
+
`const registry = Object.create(null)`,
|
|
50
|
+
`for (const path in modules) {`,
|
|
51
|
+
` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
|
|
52
|
+
` registry[toComponentName(path, ${JSON.stringify(root)}, extension)] = modules[path]`,
|
|
53
|
+
`}`,
|
|
54
|
+
`export const run = () => serve(registry)`,
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
if (id === RESOLVED_MAIN) {
|
|
58
|
+
return `import { run } from ${JSON.stringify(VIRTUAL_SERVER)}\nrun()\n`;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
config() {
|
|
62
|
+
// Register a dedicated `email` build environment and opt the app into
|
|
63
|
+
// building all environments, so a plain `vite build` emits the email
|
|
64
|
+
// bundle alongside the client assets — no separate build step required.
|
|
65
|
+
// The environment is a server consumer. Standalone builds inline Node
|
|
66
|
+
// dependencies by default so Rails runtime images do not need node_modules.
|
|
67
|
+
return {
|
|
68
|
+
builder: {},
|
|
69
|
+
environments: {
|
|
70
|
+
[EMAIL_ENVIRONMENT]: {
|
|
71
|
+
...(standalone ? { resolve: { noExternal: true } } : {}),
|
|
72
|
+
build: {
|
|
73
|
+
ssr: true,
|
|
74
|
+
outDir: OUT_DIR,
|
|
75
|
+
emptyOutDir: true,
|
|
76
|
+
rollupOptions: {
|
|
77
|
+
input: VIRTUAL_MAIN,
|
|
78
|
+
output: { entryFileNames: BUNDLE_FILE },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const metadata = {
|
|
87
|
+
emails: {
|
|
88
|
+
path,
|
|
89
|
+
extensions,
|
|
90
|
+
ignore,
|
|
91
|
+
},
|
|
92
|
+
standalone,
|
|
93
|
+
outDir: OUT_DIR,
|
|
94
|
+
bundleFile: BUNDLE_FILE,
|
|
95
|
+
};
|
|
96
|
+
Object.defineProperty(plugin, CONFIG_SYMBOL, {
|
|
97
|
+
value: {
|
|
98
|
+
...metadata,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
return plugin;
|
|
102
|
+
}
|
|
103
|
+
export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Options as ReactEmailRenderOptions } from "@react-email/render";
|
|
2
|
+
import React from "react";
|
|
3
|
+
export type EmailModule = {
|
|
4
|
+
default: React.ComponentType<Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
export type EmailLoader = EmailModule | (() => Promise<EmailModule>);
|
|
7
|
+
export type EmailRegistry = Record<string, EmailLoader>;
|
|
8
|
+
export type RenderRequest = {
|
|
9
|
+
component: string;
|
|
10
|
+
props?: Record<string, unknown>;
|
|
11
|
+
renderOptions?: EmailRenderOptions;
|
|
12
|
+
};
|
|
13
|
+
export type HealthRequest = {
|
|
14
|
+
health: true;
|
|
15
|
+
};
|
|
16
|
+
export type RenderedEmail = {
|
|
17
|
+
html: string;
|
|
18
|
+
text: string;
|
|
19
|
+
};
|
|
20
|
+
export type EmailRenderOptions = {
|
|
21
|
+
html?: ReactEmailRenderOptions;
|
|
22
|
+
text?: ReactEmailRenderOptions;
|
|
23
|
+
};
|
|
24
|
+
export declare function toComponentName(globPath: string, root: string, extension: string): string;
|
|
25
|
+
export declare function renderEmail(request: RenderRequest, registry: EmailRegistry): Promise<RenderedEmail>;
|
|
26
|
+
export declare function serve(registry: EmailRegistry): Promise<void>;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { render } from "@react-email/render";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js";
|
|
4
|
+
export function toComponentName(globPath, root, extension) {
|
|
5
|
+
const start = globPath.lastIndexOf(root) + root.length;
|
|
6
|
+
return globPath.slice(start, globPath.length - extension.length);
|
|
7
|
+
}
|
|
8
|
+
export async function renderEmail(request, registry) {
|
|
9
|
+
const loader = registry[request.component];
|
|
10
|
+
if (!loader)
|
|
11
|
+
throw new Error(`React email component not found: ${request.component}`);
|
|
12
|
+
const mod = typeof loader === "function" ? await loader() : loader;
|
|
13
|
+
const element = React.createElement(mod.default, request.props ?? {});
|
|
14
|
+
// @react-email/render re-renders the tree per call, so html and text are two passes.
|
|
15
|
+
return {
|
|
16
|
+
html: await render(element, { ...request.renderOptions?.html, plainText: false }),
|
|
17
|
+
text: await render(element, { ...request.renderOptions?.text, plainText: true }),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export async function serve(registry) {
|
|
21
|
+
if (process.argv.includes("--persistent")) {
|
|
22
|
+
await servePersistent(registry, isolateStdout());
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (process.argv.includes("--health")) {
|
|
26
|
+
process.stdout.write(JSON.stringify(okResponse()));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const write = isolateStdout();
|
|
30
|
+
try {
|
|
31
|
+
const request = JSON.parse(await readStdin());
|
|
32
|
+
write(JSON.stringify({ ...(await renderEmail(request, registry)), ...protocolMetadata() }));
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
process.stderr.write(error instanceof Error ? error.message : "React Email render failed");
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Reserve stdout for the JSON render protocol. Stray writes from email components
|
|
40
|
+
// or their dependencies — including console.log, which Node routes through
|
|
41
|
+
// process.stdout.write — are diverted to stderr so they cannot corrupt or desync
|
|
42
|
+
// a response frame. Returns the writer to use for protocol output.
|
|
43
|
+
function isolateStdout() {
|
|
44
|
+
const protocolWrite = process.stdout.write.bind(process.stdout);
|
|
45
|
+
process.stdout.write = ((chunk) => process.stderr.write(chunk));
|
|
46
|
+
return (chunk) => protocolWrite(chunk);
|
|
47
|
+
}
|
|
48
|
+
function readStdin() {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let data = "";
|
|
51
|
+
process.stdin.setEncoding("utf8");
|
|
52
|
+
process.stdin.on("data", (chunk) => {
|
|
53
|
+
data += chunk;
|
|
54
|
+
});
|
|
55
|
+
process.stdin.on("end", () => resolve(data));
|
|
56
|
+
process.stdin.on("error", reject);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function servePersistent(registry, write) {
|
|
60
|
+
process.stdin.setEncoding("utf8");
|
|
61
|
+
let pending = "";
|
|
62
|
+
for await (const chunk of process.stdin) {
|
|
63
|
+
pending += chunk;
|
|
64
|
+
let separator = pending.indexOf("\n");
|
|
65
|
+
while (separator !== -1) {
|
|
66
|
+
const line = pending.slice(0, separator);
|
|
67
|
+
pending = pending.slice(separator + 1);
|
|
68
|
+
if (line.trim())
|
|
69
|
+
await writePersistentResponse(line, registry, write);
|
|
70
|
+
separator = pending.indexOf("\n");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function writePersistentResponse(line, registry, write) {
|
|
75
|
+
try {
|
|
76
|
+
const request = JSON.parse(line);
|
|
77
|
+
if ("health" in request) {
|
|
78
|
+
write(`${JSON.stringify(okResponse())}\n`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
write(`${JSON.stringify({ ok: true, ...(await renderEmail(request, registry)), ...protocolMetadata() })}\n`);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
write(`${JSON.stringify({
|
|
85
|
+
ok: false,
|
|
86
|
+
error: error instanceof Error ? error.message : "React Email render failed",
|
|
87
|
+
})}\n`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function okResponse() {
|
|
91
|
+
return { ok: true, ...protocolMetadata() };
|
|
92
|
+
}
|
|
93
|
+
function protocolMetadata() {
|
|
94
|
+
return {
|
|
95
|
+
protocolVersion: RENDER_PROTOCOL_VERSION,
|
|
96
|
+
packageVersion: VERSION,
|
|
97
|
+
};
|
|
98
|
+
}
|
package/dist/version.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-email-rails",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Build and send emails using React and Rails",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://github.com/heysupertape/react-email-rails#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/heysupertape/react-email-rails/issues"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"rails",
|
|
13
|
+
"react-email",
|
|
14
|
+
"actionmailer",
|
|
15
|
+
"vite",
|
|
16
|
+
"email"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/heysupertape/react-email-rails.git",
|
|
21
|
+
"directory": "vite"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"dist",
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./runtime": {
|
|
34
|
+
"types": "./dist/runtime.d.ts",
|
|
35
|
+
"import": "./dist/runtime.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"bin": {
|
|
40
|
+
"react-email-rails-config": "./bin/config.mjs",
|
|
41
|
+
"react-email-rails-dev": "./bin/dev.mjs"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsgo -p tsconfig.json",
|
|
45
|
+
"check:version": "ruby ../scripts/check_version_sync.rb",
|
|
46
|
+
"lint": "oxlint src test bin",
|
|
47
|
+
"format": "oxfmt src test bin",
|
|
48
|
+
"typecheck": "tsgo -p tsconfig.json --noEmit",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"sync:version": "ruby ../scripts/sync_version.rb",
|
|
51
|
+
"ci": "pnpm run check:version && pnpm build && pnpm lint && pnpm typecheck && pnpm test",
|
|
52
|
+
"prepack": "pnpm run sync:version && pnpm build",
|
|
53
|
+
"prepublishOnly": "pnpm run check:version"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20.19.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@react-email/render": "^2.0.0",
|
|
60
|
+
"react": "^18.0 || ^19.0",
|
|
61
|
+
"vite": "^7.0.0 || ^8.0.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@react-email/render": "^2.0.8",
|
|
65
|
+
"@types/node": "^25.9.1",
|
|
66
|
+
"@types/react": "^19.2.15",
|
|
67
|
+
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
|
68
|
+
"oxfmt": "^0.52.0",
|
|
69
|
+
"oxlint": "^1.67.0",
|
|
70
|
+
"oxlint-tsgolint": "^0.23.0",
|
|
71
|
+
"react": "^19.2.6",
|
|
72
|
+
"react-dom": "^19.2.6",
|
|
73
|
+
"vite": "^8.0.14",
|
|
74
|
+
"vitest": "^4.1.7"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Plugin } from "vite"
|
|
2
|
+
|
|
3
|
+
export type EmailsOption =
|
|
4
|
+
| string
|
|
5
|
+
| {
|
|
6
|
+
path?: string
|
|
7
|
+
extension?: string | string[]
|
|
8
|
+
ignore?: string | string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ReactEmailRailsOptions = {
|
|
12
|
+
emails?: EmailsOption
|
|
13
|
+
standalone?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type PluginMetadata = {
|
|
17
|
+
emails: {
|
|
18
|
+
path: string
|
|
19
|
+
extensions: string[]
|
|
20
|
+
ignore: string[]
|
|
21
|
+
}
|
|
22
|
+
standalone: boolean
|
|
23
|
+
outDir: string
|
|
24
|
+
bundleFile: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_IGNORE = ["**/_*", "**/_*/**"]
|
|
28
|
+
const DEFAULT_EXTENSIONS = [".tsx", ".jsx"]
|
|
29
|
+
|
|
30
|
+
const VIRTUAL_SERVER = "virtual:react-email-rails/server"
|
|
31
|
+
const VIRTUAL_MAIN = "virtual:react-email-rails/main"
|
|
32
|
+
const RESOLVED_SERVER = `\0${VIRTUAL_SERVER}`
|
|
33
|
+
const RESOLVED_MAIN = `\0${VIRTUAL_MAIN}`
|
|
34
|
+
|
|
35
|
+
// The dedicated build environment that emits the server-side email bundle.
|
|
36
|
+
export const EMAIL_ENVIRONMENT = "email"
|
|
37
|
+
const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
|
|
38
|
+
const OUT_DIR = "tmp/react-email-rails"
|
|
39
|
+
const BUNDLE_FILE = "emails.js"
|
|
40
|
+
|
|
41
|
+
export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
42
|
+
const emails =
|
|
43
|
+
typeof options.emails === "string" ? { path: options.emails } : (options.emails ?? {})
|
|
44
|
+
const path = (emails.path ?? "app/javascript/emails").replace(/^\/|\/$/g, "")
|
|
45
|
+
const rawExtensions =
|
|
46
|
+
emails.extension === undefined
|
|
47
|
+
? DEFAULT_EXTENSIONS
|
|
48
|
+
: Array.isArray(emails.extension)
|
|
49
|
+
? emails.extension
|
|
50
|
+
: [emails.extension]
|
|
51
|
+
const extensions = rawExtensions
|
|
52
|
+
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
|
|
53
|
+
.map((extension, index) => ({ extension, index }))
|
|
54
|
+
.sort(
|
|
55
|
+
(left, right) => right.extension.length - left.extension.length || left.index - right.index,
|
|
56
|
+
)
|
|
57
|
+
.map(({ extension }) => extension)
|
|
58
|
+
const standalone = options.standalone ?? true
|
|
59
|
+
|
|
60
|
+
const root = `/${path}/`
|
|
61
|
+
const pattern =
|
|
62
|
+
extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`
|
|
63
|
+
const ignore =
|
|
64
|
+
emails.ignore === undefined
|
|
65
|
+
? DEFAULT_IGNORE
|
|
66
|
+
: Array.isArray(emails.ignore)
|
|
67
|
+
? emails.ignore
|
|
68
|
+
: [emails.ignore]
|
|
69
|
+
const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)]
|
|
70
|
+
const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns)
|
|
71
|
+
|
|
72
|
+
const plugin: Plugin = {
|
|
73
|
+
name: "react-email-rails",
|
|
74
|
+
|
|
75
|
+
resolveId(id) {
|
|
76
|
+
if (id === VIRTUAL_SERVER) return RESOLVED_SERVER
|
|
77
|
+
if (id === VIRTUAL_MAIN) return RESOLVED_MAIN
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
load(id) {
|
|
81
|
+
if (id === RESOLVED_SERVER) {
|
|
82
|
+
return [
|
|
83
|
+
`import { serve, toComponentName } from "react-email-rails/runtime"`,
|
|
84
|
+
`const modules = import.meta.glob(${globArg})`,
|
|
85
|
+
`const extensions = ${JSON.stringify(extensions)}`,
|
|
86
|
+
`const registry = Object.create(null)`,
|
|
87
|
+
`for (const path in modules) {`,
|
|
88
|
+
` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
|
|
89
|
+
` registry[toComponentName(path, ${JSON.stringify(root)}, extension)] = modules[path]`,
|
|
90
|
+
`}`,
|
|
91
|
+
`export const run = () => serve(registry)`,
|
|
92
|
+
].join("\n")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (id === RESOLVED_MAIN) {
|
|
96
|
+
return `import { run } from ${JSON.stringify(VIRTUAL_SERVER)}\nrun()\n`
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
config() {
|
|
101
|
+
// Register a dedicated `email` build environment and opt the app into
|
|
102
|
+
// building all environments, so a plain `vite build` emits the email
|
|
103
|
+
// bundle alongside the client assets — no separate build step required.
|
|
104
|
+
// The environment is a server consumer. Standalone builds inline Node
|
|
105
|
+
// dependencies by default so Rails runtime images do not need node_modules.
|
|
106
|
+
return {
|
|
107
|
+
builder: {},
|
|
108
|
+
environments: {
|
|
109
|
+
[EMAIL_ENVIRONMENT]: {
|
|
110
|
+
...(standalone ? { resolve: { noExternal: true } } : {}),
|
|
111
|
+
build: {
|
|
112
|
+
ssr: true,
|
|
113
|
+
outDir: OUT_DIR,
|
|
114
|
+
emptyOutDir: true,
|
|
115
|
+
rollupOptions: {
|
|
116
|
+
input: VIRTUAL_MAIN,
|
|
117
|
+
output: { entryFileNames: BUNDLE_FILE },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const metadata: PluginMetadata = {
|
|
127
|
+
emails: {
|
|
128
|
+
path,
|
|
129
|
+
extensions,
|
|
130
|
+
ignore,
|
|
131
|
+
},
|
|
132
|
+
standalone,
|
|
133
|
+
outDir: OUT_DIR,
|
|
134
|
+
bundleFile: BUNDLE_FILE,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
Object.defineProperty(plugin, CONFIG_SYMBOL, {
|
|
138
|
+
value: {
|
|
139
|
+
...metadata,
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
return plugin
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type {
|
|
147
|
+
EmailModule,
|
|
148
|
+
EmailRegistry,
|
|
149
|
+
EmailRenderOptions,
|
|
150
|
+
RenderedEmail,
|
|
151
|
+
RenderRequest,
|
|
152
|
+
} from "./runtime.js"
|
|
153
|
+
export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js"
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { render, type Options as ReactEmailRenderOptions } from "@react-email/render"
|
|
2
|
+
import React from "react"
|
|
3
|
+
|
|
4
|
+
import { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js"
|
|
5
|
+
|
|
6
|
+
export type EmailModule = {
|
|
7
|
+
default: React.ComponentType<Record<string, unknown>>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type EmailLoader = EmailModule | (() => Promise<EmailModule>)
|
|
11
|
+
export type EmailRegistry = Record<string, EmailLoader>
|
|
12
|
+
|
|
13
|
+
export type RenderRequest = {
|
|
14
|
+
component: string
|
|
15
|
+
props?: Record<string, unknown>
|
|
16
|
+
renderOptions?: EmailRenderOptions
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type HealthRequest = {
|
|
20
|
+
health: true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RenderedEmail = {
|
|
24
|
+
html: string
|
|
25
|
+
text: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ProtocolMetadata = {
|
|
29
|
+
protocolVersion: number
|
|
30
|
+
packageVersion: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type EmailRenderOptions = {
|
|
34
|
+
html?: ReactEmailRenderOptions
|
|
35
|
+
text?: ReactEmailRenderOptions
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function toComponentName(globPath: string, root: string, extension: string): string {
|
|
39
|
+
const start = globPath.lastIndexOf(root) + root.length
|
|
40
|
+
return globPath.slice(start, globPath.length - extension.length)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function renderEmail(
|
|
44
|
+
request: RenderRequest,
|
|
45
|
+
registry: EmailRegistry,
|
|
46
|
+
): Promise<RenderedEmail> {
|
|
47
|
+
const loader = registry[request.component]
|
|
48
|
+
if (!loader) throw new Error(`React email component not found: ${request.component}`)
|
|
49
|
+
|
|
50
|
+
const mod = typeof loader === "function" ? await loader() : loader
|
|
51
|
+
const element = React.createElement(mod.default, request.props ?? {})
|
|
52
|
+
|
|
53
|
+
// @react-email/render re-renders the tree per call, so html and text are two passes.
|
|
54
|
+
return {
|
|
55
|
+
html: await render(element, { ...request.renderOptions?.html, plainText: false }),
|
|
56
|
+
text: await render(element, { ...request.renderOptions?.text, plainText: true }),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function serve(registry: EmailRegistry): Promise<void> {
|
|
61
|
+
if (process.argv.includes("--persistent")) {
|
|
62
|
+
await servePersistent(registry, isolateStdout())
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (process.argv.includes("--health")) {
|
|
67
|
+
process.stdout.write(JSON.stringify(okResponse()))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const write = isolateStdout()
|
|
72
|
+
try {
|
|
73
|
+
const request = JSON.parse(await readStdin()) as RenderRequest
|
|
74
|
+
write(JSON.stringify({ ...(await renderEmail(request, registry)), ...protocolMetadata() }))
|
|
75
|
+
} catch (error) {
|
|
76
|
+
process.stderr.write(error instanceof Error ? error.message : "React Email render failed")
|
|
77
|
+
process.exitCode = 1
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Reserve stdout for the JSON render protocol. Stray writes from email components
|
|
82
|
+
// or their dependencies — including console.log, which Node routes through
|
|
83
|
+
// process.stdout.write — are diverted to stderr so they cannot corrupt or desync
|
|
84
|
+
// a response frame. Returns the writer to use for protocol output.
|
|
85
|
+
function isolateStdout(): (chunk: string) => boolean {
|
|
86
|
+
const protocolWrite = process.stdout.write.bind(process.stdout)
|
|
87
|
+
process.stdout.write = ((chunk: string | Uint8Array): boolean =>
|
|
88
|
+
process.stderr.write(chunk)) as typeof process.stdout.write
|
|
89
|
+
return (chunk) => protocolWrite(chunk)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readStdin(): Promise<string> {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
let data = ""
|
|
95
|
+
process.stdin.setEncoding("utf8")
|
|
96
|
+
process.stdin.on("data", (chunk) => {
|
|
97
|
+
data += chunk
|
|
98
|
+
})
|
|
99
|
+
process.stdin.on("end", () => resolve(data))
|
|
100
|
+
process.stdin.on("error", reject)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function servePersistent(
|
|
105
|
+
registry: EmailRegistry,
|
|
106
|
+
write: (chunk: string) => boolean,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
process.stdin.setEncoding("utf8")
|
|
109
|
+
|
|
110
|
+
let pending = ""
|
|
111
|
+
for await (const chunk of process.stdin) {
|
|
112
|
+
pending += chunk
|
|
113
|
+
|
|
114
|
+
let separator = pending.indexOf("\n")
|
|
115
|
+
while (separator !== -1) {
|
|
116
|
+
const line = pending.slice(0, separator)
|
|
117
|
+
pending = pending.slice(separator + 1)
|
|
118
|
+
|
|
119
|
+
if (line.trim()) await writePersistentResponse(line, registry, write)
|
|
120
|
+
separator = pending.indexOf("\n")
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function writePersistentResponse(
|
|
126
|
+
line: string,
|
|
127
|
+
registry: EmailRegistry,
|
|
128
|
+
write: (chunk: string) => boolean,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
try {
|
|
131
|
+
const request = JSON.parse(line) as RenderRequest | HealthRequest
|
|
132
|
+
if ("health" in request) {
|
|
133
|
+
write(`${JSON.stringify(okResponse())}\n`)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
write(`${JSON.stringify({ ok: true, ...(await renderEmail(request, registry)), ...protocolMetadata() })}\n`)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
write(
|
|
140
|
+
`${JSON.stringify({
|
|
141
|
+
ok: false,
|
|
142
|
+
error: error instanceof Error ? error.message : "React Email render failed",
|
|
143
|
+
})}\n`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function okResponse(): { ok: true } & ProtocolMetadata {
|
|
149
|
+
return { ok: true, ...protocolMetadata() }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function protocolMetadata(): ProtocolMetadata {
|
|
153
|
+
return {
|
|
154
|
+
protocolVersion: RENDER_PROTOCOL_VERSION,
|
|
155
|
+
packageVersion: VERSION,
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/version.ts
ADDED