react-email-rails 0.1.3 → 0.2.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.
@@ -0,0 +1,18 @@
1
+ import { type Extensions } from "@tiptap/core";
2
+ import type { DroppedNode, RenderResult } from "./runtime.js";
3
+ export type { DroppedNode };
4
+ export type DocumentRenderer = {
5
+ buildExtensions: (context: unknown) => Extensions;
6
+ transformDocument?: (document: unknown, context: unknown) => unknown;
7
+ getPreview?: (context: unknown) => string | null;
8
+ };
9
+ export type DocumentLoader = DocumentRenderer | (() => Promise<DocumentRenderer>);
10
+ export type DocumentRegistry = Record<string, DocumentLoader>;
11
+ export type RenderDocumentRequest = {
12
+ kind: "document";
13
+ type: string;
14
+ document: unknown;
15
+ context?: unknown;
16
+ preview?: string | null;
17
+ };
18
+ export declare function composeDocument(request: RenderDocumentRequest, registry: DocumentRegistry): Promise<RenderResult>;
@@ -0,0 +1,69 @@
1
+ import { EmailNode, composeReactEmail } from "@react-email/editor/core";
2
+ import { StarterKit } from "@react-email/editor/extensions";
3
+ import { EmailTheming } from "@react-email/editor/plugins";
4
+ import { getSchema, resolveExtensions } from "@tiptap/core";
5
+ // Editor-bundled structural nodes render to null by design. Derive the list from
6
+ // the installed editor package so warning filtering tracks version changes.
7
+ const STRUCTURAL_NODE_TYPES = new Set(resolveExtensions([StarterKit, EmailTheming])
8
+ .filter((extension) => extension.type === "node" && !(extension instanceof EmailNode))
9
+ .map((extension) => extension.name));
10
+ // composeReactEmail renders a node as null when no extension matches its type or
11
+ // the match is not an EmailNode. Mirror that predicate over the document so
12
+ // warnings report the silent case: an in-schema node with no email renderer.
13
+ function collectDroppedNodes(document, extensions) {
14
+ const byName = new Map();
15
+ for (const extension of extensions)
16
+ byName.set(extension.name, extension);
17
+ const counts = new Map();
18
+ const walk = (content) => {
19
+ if (!Array.isArray(content))
20
+ return;
21
+ for (const node of content) {
22
+ if (!node || typeof node !== "object")
23
+ continue;
24
+ const type = node.type;
25
+ if (typeof type !== "string" || STRUCTURAL_NODE_TYPES.has(type))
26
+ continue;
27
+ const extension = byName.get(type);
28
+ if (!extension || !(extension instanceof EmailNode)) {
29
+ counts.set(type, (counts.get(type) ?? 0) + 1);
30
+ continue;
31
+ }
32
+ walk(node.content);
33
+ }
34
+ };
35
+ walk(document.content);
36
+ return [...counts].map(([type, count]) => ({ type, count }));
37
+ }
38
+ export async function composeDocument(request, registry) {
39
+ // Fail legibly if the optional editor peers are present but their shape shifted.
40
+ if (typeof composeReactEmail !== "function" ||
41
+ typeof resolveExtensions !== "function" ||
42
+ typeof getSchema !== "function") {
43
+ throw new Error("@react-email/editor or @tiptap/core is missing expected exports (composeReactEmail/resolveExtensions/getSchema); check the installed versions");
44
+ }
45
+ const loader = registry[request.type];
46
+ if (!loader)
47
+ throw new Error(`React email document renderer not found: ${request.type}`);
48
+ const renderer = typeof loader === "function" ? await loader() : loader;
49
+ if (typeof renderer.buildExtensions !== "function") {
50
+ throw new Error(`React email document renderer must export a buildExtensions function: ${request.type}`);
51
+ }
52
+ const document = renderer.transformDocument?.(request.document, request.context) ?? request.document;
53
+ const extensions = resolveExtensions(renderer.buildExtensions(request.context));
54
+ const schema = getSchema(extensions);
55
+ const warnings = collectDroppedNodes(document, extensions);
56
+ // The minimal editor composeReactEmail reads, built headless (no DOM, no view).
57
+ // state.doc is required: EmailTheming finds the globalContent theme node through it.
58
+ const editor = {
59
+ getJSON: () => document,
60
+ extensionManager: { extensions },
61
+ schema,
62
+ state: { doc: schema.nodeFromJSON(document) },
63
+ };
64
+ // composeReactEmail takes `preview?: string`; omit it rather than pass null.
65
+ const preview = request.preview ?? renderer.getPreview?.(request.context) ?? null;
66
+ const params = preview === null ? { editor } : { editor, preview };
67
+ const { html, text } = await composeReactEmail(params);
68
+ return warnings.length > 0 ? { html, text, warnings } : { html, text };
69
+ }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export type EmailsOption = string | {
6
6
  };
7
7
  export type ReactEmailRailsOptions = {
8
8
  emails?: EmailsOption;
9
+ documents?: EmailsOption | boolean;
9
10
  standalone?: boolean;
10
11
  vite?: ReactEmailRailsViteOptions;
11
12
  };
@@ -14,5 +15,5 @@ export type ReactEmailRailsViteOptions = Pick<UserConfig, "assetsInclude" | "css
14
15
  };
15
16
  export declare const EMAIL_ENVIRONMENT = "email";
16
17
  export declare function reactEmailRails(options?: ReactEmailRailsOptions): Plugin;
17
- export type { EmailModule, EmailRegistry, EmailRenderOptions, RenderedEmail, RenderRequest, } from "./runtime.js";
18
+ export type { EmailModule, EmailRegistry, EmailRenderOptions, RenderedEmail, RenderRequest, RenderResult, } from "./runtime.js";
18
19
  export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js";
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  const DEFAULT_IGNORE = ["**/_*", "**/_*/**"];
2
- const DEFAULT_EXTENSIONS = [".tsx", ".jsx"];
2
+ const DEFAULT_EMAIL_PATH = "app/javascript/emails";
3
+ const DEFAULT_EMAIL_EXTENSIONS = [".tsx", ".jsx"];
4
+ const DEFAULT_DOCUMENT_PATH = "app/javascript/documents";
5
+ const DEFAULT_DOCUMENT_EXTENSIONS = [".ts", ".tsx"];
3
6
  const VIRTUAL_SERVER = "virtual:react-email-rails/server";
4
7
  const VIRTUAL_MAIN = "virtual:react-email-rails/main";
5
8
  const RESOLVED_SERVER = `\0${VIRTUAL_SERVER}`;
@@ -11,29 +14,36 @@ const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
11
14
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite");
12
15
  const OUT_DIR = "tmp/react-email-rails";
13
16
  const BUNDLE_FILE = "emails.js";
14
- export function reactEmailRails(options = {}) {
15
- const emails = typeof options.emails === "string" ? { path: options.emails } : (options.emails ?? {});
16
- const path = (emails.path ?? "app/javascript/emails").replace(/^\/|\/$/g, "");
17
- const rawExtensions = emails.extension === undefined
18
- ? DEFAULT_EXTENSIONS
19
- : Array.isArray(emails.extension)
20
- ? emails.extension
21
- : [emails.extension];
17
+ function normalizeSource(option, defaultPath, defaultExtensions) {
18
+ const source = typeof option === "string" ? { path: option } : (option ?? {});
19
+ const path = (source.path ?? defaultPath).replace(/^\/|\/$/g, "");
20
+ const rawExtensions = source.extension === undefined
21
+ ? defaultExtensions
22
+ : Array.isArray(source.extension)
23
+ ? source.extension
24
+ : [source.extension];
22
25
  const extensions = rawExtensions
23
26
  .map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
24
27
  .map((extension, index) => ({ extension, index }))
25
28
  .sort((left, right) => right.extension.length - left.extension.length || left.index - right.index)
26
29
  .map(({ extension }) => extension);
27
- const standalone = options.standalone ?? true;
28
30
  const root = `/${path}/`;
29
31
  const pattern = extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`;
30
- const ignore = emails.ignore === undefined
32
+ const ignore = source.ignore === undefined
31
33
  ? DEFAULT_IGNORE
32
- : Array.isArray(emails.ignore)
33
- ? emails.ignore
34
- : [emails.ignore];
34
+ : Array.isArray(source.ignore)
35
+ ? source.ignore
36
+ : [source.ignore];
35
37
  const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)];
36
38
  const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns);
39
+ return { path, extensions, ignore, root, globArg };
40
+ }
41
+ export function reactEmailRails(options = {}) {
42
+ const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS);
43
+ const documentSource = options.documents === undefined || options.documents === false
44
+ ? null
45
+ : normalizeSource(options.documents === true ? undefined : options.documents, DEFAULT_DOCUMENT_PATH, DEFAULT_DOCUMENT_EXTENSIONS);
46
+ const standalone = options.standalone ?? true;
37
47
  const plugin = {
38
48
  name: "react-email-rails",
39
49
  resolveId: {
@@ -49,17 +59,18 @@ export function reactEmailRails(options = {}) {
49
59
  filter: { id: VIRTUAL_MODULE_PATTERN },
50
60
  handler(id) {
51
61
  if (id === RESOLVED_SERVER) {
52
- return [
53
- `import { serve, toComponentName } from "react-email-rails/runtime"`,
54
- `const modules = import.meta.glob(${globArg})`,
55
- `const extensions = ${JSON.stringify(extensions)}`,
56
- `const registry = Object.create(null)`,
57
- `for (const path in modules) {`,
58
- ` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
59
- ` registry[toComponentName(path, ${JSON.stringify(root)}, extension)] = modules[path]`,
60
- `}`,
61
- `export const run = () => serve(registry)`,
62
- ].join("\n");
62
+ const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`];
63
+ // Imported only here, so the editor stays out of the email build graph when off.
64
+ if (documentSource)
65
+ lines.push(`import { composeDocument } from "react-email-rails/document"`);
66
+ lines.push(`const modules = import.meta.glob(${emailSource.globArg})`, `const extensions = ${JSON.stringify(emailSource.extensions)}`, `const registry = Object.create(null)`, `for (const path in modules) {`, ` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`, ` registry[toComponentName(path, ${JSON.stringify(emailSource.root)}, extension)] = modules[path]`, `}`);
67
+ if (documentSource) {
68
+ lines.push(`const documentModules = import.meta.glob(${documentSource.globArg})`, `const documentExtensions = ${JSON.stringify(documentSource.extensions)}`, `const documentRegistry = Object.create(null)`, `for (const path in documentModules) {`, ` const extension = documentExtensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`, ` documentRegistry[toComponentName(path, ${JSON.stringify(documentSource.root)}, extension)] = documentModules[path]`, `}`, `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument })`);
69
+ }
70
+ else {
71
+ lines.push(`export const run = () => serve(registry)`);
72
+ }
73
+ return lines.join("\n");
63
74
  }
64
75
  if (id === RESOLVED_MAIN) {
65
76
  return `import { run } from ${JSON.stringify(VIRTUAL_SERVER)}\nrun()\n`;
@@ -94,10 +105,17 @@ export function reactEmailRails(options = {}) {
94
105
  };
95
106
  const metadata = {
96
107
  emails: {
97
- path,
98
- extensions,
99
- ignore,
108
+ path: emailSource.path,
109
+ extensions: emailSource.extensions,
110
+ ignore: emailSource.ignore,
100
111
  },
112
+ ...(documentSource && {
113
+ documents: {
114
+ path: documentSource.path,
115
+ extensions: documentSource.extensions,
116
+ ignore: documentSource.ignore,
117
+ },
118
+ }),
101
119
  standalone,
102
120
  outDir: OUT_DIR,
103
121
  bundleFile: BUNDLE_FILE,
package/dist/runtime.d.ts CHANGED
@@ -17,10 +17,28 @@ export type RenderedEmail = {
17
17
  html: string;
18
18
  text: string;
19
19
  };
20
+ export type RenderDocumentRequest = {
21
+ kind: "document";
22
+ type: string;
23
+ document: unknown;
24
+ context?: unknown;
25
+ preview?: string | null;
26
+ };
27
+ export type DroppedNode = {
28
+ type: string;
29
+ count: number;
30
+ };
31
+ export type RenderResult = RenderedEmail & {
32
+ warnings?: DroppedNode[];
33
+ };
34
+ export type DocumentSupport<Registry = unknown> = {
35
+ registry: Registry;
36
+ compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>;
37
+ };
20
38
  export type EmailRenderOptions = {
21
39
  html?: ReactEmailRenderOptions;
22
40
  text?: ReactEmailRenderOptions;
23
41
  };
24
42
  export declare function toComponentName(globPath: string, root: string, extension: string): string;
25
43
  export declare function renderEmail(request: RenderRequest, registry: EmailRegistry): Promise<RenderedEmail>;
26
- export declare function serve(registry: EmailRegistry): Promise<void>;
44
+ export declare function serve<Registry = unknown>(registry: EmailRegistry, documents?: DocumentSupport<Registry> | null): Promise<void>;
package/dist/runtime.js CHANGED
@@ -17,9 +17,26 @@ export async function renderEmail(request, registry) {
17
17
  text: await render(element, { ...request.renderOptions?.text, plainText: true }),
18
18
  };
19
19
  }
20
- export async function serve(registry) {
20
+ function isDocumentRequest(request) {
21
+ return (request !== null &&
22
+ typeof request === "object" &&
23
+ request.kind === "document");
24
+ }
25
+ function isHealthRequest(request) {
26
+ return request !== null && typeof request === "object" && "health" in request;
27
+ }
28
+ // Requests without a document kind are component renders, preserving the email path.
29
+ async function renderRequest(request, registry, documents) {
30
+ if (isDocumentRequest(request)) {
31
+ if (!documents)
32
+ throw new Error("React email document rendering is not enabled");
33
+ return documents.compose(request, documents.registry);
34
+ }
35
+ return renderEmail(request, registry);
36
+ }
37
+ export async function serve(registry, documents = null) {
21
38
  if (process.argv.includes("--persistent")) {
22
- await servePersistent(registry, isolateStdout());
39
+ await servePersistent(registry, documents, isolateStdout());
23
40
  return;
24
41
  }
25
42
  if (process.argv.includes("--health")) {
@@ -29,7 +46,10 @@ export async function serve(registry) {
29
46
  const write = isolateStdout();
30
47
  try {
31
48
  const request = JSON.parse(await readStdin());
32
- write(JSON.stringify({ ...(await renderEmail(request, registry)), ...protocolMetadata() }));
49
+ write(JSON.stringify({
50
+ ...(await renderRequest(request, registry, documents)),
51
+ ...protocolMetadata(),
52
+ }));
33
53
  }
34
54
  catch (error) {
35
55
  process.stderr.write(error instanceof Error ? error.message : "React Email render failed");
@@ -56,7 +76,7 @@ function readStdin() {
56
76
  process.stdin.on("error", reject);
57
77
  });
58
78
  }
59
- async function servePersistent(registry, write) {
79
+ async function servePersistent(registry, documents, write) {
60
80
  process.stdin.setEncoding("utf8");
61
81
  let pending = "";
62
82
  for await (const chunk of process.stdin) {
@@ -66,19 +86,19 @@ async function servePersistent(registry, write) {
66
86
  const line = pending.slice(0, separator);
67
87
  pending = pending.slice(separator + 1);
68
88
  if (line.trim())
69
- await writePersistentResponse(line, registry, write);
89
+ await writePersistentResponse(line, registry, documents, write);
70
90
  separator = pending.indexOf("\n");
71
91
  }
72
92
  }
73
93
  }
74
- async function writePersistentResponse(line, registry, write) {
94
+ async function writePersistentResponse(line, registry, documents, write) {
75
95
  try {
76
96
  const request = JSON.parse(line);
77
- if ("health" in request) {
97
+ if (isHealthRequest(request)) {
78
98
  write(`${JSON.stringify(okResponse())}\n`);
79
99
  return;
80
100
  }
81
- write(`${JSON.stringify({ ok: true, ...(await renderEmail(request, registry)), ...protocolMetadata() })}\n`);
101
+ write(`${JSON.stringify({ ok: true, ...(await renderRequest(request, registry, documents)), ...protocolMetadata() })}\n`);
82
102
  }
83
103
  catch (error) {
84
104
  write(`${JSON.stringify({
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.3";
2
- export declare const RENDER_PROTOCOL_VERSION = 1;
1
+ export declare const VERSION = "0.2.0";
2
+ export declare const RENDER_PROTOCOL_VERSION = 2;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.1.3";
2
- export const RENDER_PROTOCOL_VERSION = 1;
1
+ export const VERSION = "0.2.0";
2
+ export const RENDER_PROTOCOL_VERSION = 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email-rails",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Build and send emails using React and Rails",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,6 +33,10 @@
33
33
  "./runtime": {
34
34
  "types": "./dist/runtime.d.ts",
35
35
  "import": "./dist/runtime.js"
36
+ },
37
+ "./document": {
38
+ "types": "./dist/document.d.ts",
39
+ "import": "./dist/document.js"
36
40
  }
37
41
  },
38
42
  "types": "./dist/index.d.ts",
@@ -57,12 +61,24 @@
57
61
  "node": ">=20.19.0"
58
62
  },
59
63
  "peerDependencies": {
64
+ "@react-email/editor": "^1.5",
60
65
  "@react-email/render": "^2.0.0",
66
+ "@tiptap/core": "^3",
61
67
  "react": "^18.0 || ^19.0",
62
68
  "vite": "^7.0.0 || ^8.0.0"
63
69
  },
70
+ "peerDependenciesMeta": {
71
+ "@react-email/editor": {
72
+ "optional": true
73
+ },
74
+ "@tiptap/core": {
75
+ "optional": true
76
+ }
77
+ },
64
78
  "devDependencies": {
79
+ "@react-email/editor": "^1.5.3",
65
80
  "@react-email/render": "^2.0.8",
81
+ "@tiptap/core": "^3",
66
82
  "@types/node": "^25.9.1",
67
83
  "@types/react": "^19.2.15",
68
84
  "@typescript/native-preview": "7.0.0-dev.20260527.2",
@@ -0,0 +1,110 @@
1
+ import { EmailNode, composeReactEmail } from "@react-email/editor/core"
2
+ import { StarterKit } from "@react-email/editor/extensions"
3
+ import { EmailTheming } from "@react-email/editor/plugins"
4
+ import { getSchema, resolveExtensions, type Extensions } from "@tiptap/core"
5
+ import type { Editor } from "@tiptap/core"
6
+
7
+ import type { DroppedNode, RenderResult } from "./runtime.js"
8
+
9
+ export type { DroppedNode }
10
+
11
+ export type DocumentRenderer = {
12
+ buildExtensions: (context: unknown) => Extensions
13
+ transformDocument?: (document: unknown, context: unknown) => unknown
14
+ getPreview?: (context: unknown) => string | null
15
+ }
16
+
17
+ // Editor-bundled structural nodes render to null by design. Derive the list from
18
+ // the installed editor package so warning filtering tracks version changes.
19
+ const STRUCTURAL_NODE_TYPES: ReadonlySet<string> = new Set(
20
+ resolveExtensions([StarterKit, EmailTheming])
21
+ .filter((extension) => extension.type === "node" && !(extension instanceof EmailNode))
22
+ .map((extension) => extension.name),
23
+ )
24
+
25
+ // composeReactEmail renders a node as null when no extension matches its type or
26
+ // the match is not an EmailNode. Mirror that predicate over the document so
27
+ // warnings report the silent case: an in-schema node with no email renderer.
28
+ function collectDroppedNodes(document: unknown, extensions: Extensions): DroppedNode[] {
29
+ const byName = new Map<string, Extensions[number]>()
30
+ for (const extension of extensions) byName.set(extension.name, extension)
31
+
32
+ const counts = new Map<string, number>()
33
+ const walk = (content: unknown): void => {
34
+ if (!Array.isArray(content)) return
35
+ for (const node of content) {
36
+ if (!node || typeof node !== "object") continue
37
+ const type = (node as { type?: unknown }).type
38
+ if (typeof type !== "string" || STRUCTURAL_NODE_TYPES.has(type)) continue
39
+
40
+ const extension = byName.get(type)
41
+ if (!extension || !(extension instanceof EmailNode)) {
42
+ counts.set(type, (counts.get(type) ?? 0) + 1)
43
+ continue
44
+ }
45
+ walk((node as { content?: unknown }).content)
46
+ }
47
+ }
48
+ walk((document as { content?: unknown }).content)
49
+
50
+ return [...counts].map(([type, count]) => ({ type, count }))
51
+ }
52
+
53
+ export type DocumentLoader = DocumentRenderer | (() => Promise<DocumentRenderer>)
54
+ export type DocumentRegistry = Record<string, DocumentLoader>
55
+
56
+ export type RenderDocumentRequest = {
57
+ kind: "document"
58
+ type: string
59
+ document: unknown
60
+ context?: unknown
61
+ preview?: string | null
62
+ }
63
+
64
+ export async function composeDocument(
65
+ request: RenderDocumentRequest,
66
+ registry: DocumentRegistry,
67
+ ): Promise<RenderResult> {
68
+ // Fail legibly if the optional editor peers are present but their shape shifted.
69
+ if (
70
+ typeof composeReactEmail !== "function" ||
71
+ typeof resolveExtensions !== "function" ||
72
+ typeof getSchema !== "function"
73
+ ) {
74
+ throw new Error(
75
+ "@react-email/editor or @tiptap/core is missing expected exports (composeReactEmail/resolveExtensions/getSchema); check the installed versions",
76
+ )
77
+ }
78
+
79
+ const loader = registry[request.type]
80
+ if (!loader) throw new Error(`React email document renderer not found: ${request.type}`)
81
+
82
+ const renderer = typeof loader === "function" ? await loader() : loader
83
+ if (typeof renderer.buildExtensions !== "function") {
84
+ throw new Error(
85
+ `React email document renderer must export a buildExtensions function: ${request.type}`,
86
+ )
87
+ }
88
+
89
+ const document =
90
+ renderer.transformDocument?.(request.document, request.context) ?? request.document
91
+ const extensions = resolveExtensions(renderer.buildExtensions(request.context))
92
+ const schema = getSchema(extensions)
93
+ const warnings = collectDroppedNodes(document, extensions)
94
+
95
+ // The minimal editor composeReactEmail reads, built headless (no DOM, no view).
96
+ // state.doc is required: EmailTheming finds the globalContent theme node through it.
97
+ const editor = {
98
+ getJSON: () => document,
99
+ extensionManager: { extensions },
100
+ schema,
101
+ state: { doc: schema.nodeFromJSON(document) },
102
+ } as unknown as Editor
103
+
104
+ // composeReactEmail takes `preview?: string`; omit it rather than pass null.
105
+ const preview = request.preview ?? renderer.getPreview?.(request.context) ?? null
106
+ const params = preview === null ? { editor } : { editor, preview }
107
+
108
+ const { html, text } = await composeReactEmail(params)
109
+ return warnings.length > 0 ? { html, text, warnings } : { html, text }
110
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,9 @@ export type EmailsOption =
10
10
 
11
11
  export type ReactEmailRailsOptions = {
12
12
  emails?: EmailsOption
13
+ // Editor document renderers, discovered like emails. Off by default; pass `true`
14
+ // to enable with defaults, or a path/options object to customize discovery.
15
+ documents?: EmailsOption | boolean
13
16
  standalone?: boolean
14
17
  vite?: ReactEmailRailsViteOptions
15
18
  }
@@ -21,19 +24,33 @@ export type ReactEmailRailsViteOptions = Pick<
21
24
  oxc?: unknown
22
25
  }
23
26
 
27
+ type SourceMetadata = {
28
+ path: string
29
+ extensions: string[]
30
+ ignore: string[]
31
+ }
32
+
24
33
  type PluginMetadata = {
25
- emails: {
26
- path: string
27
- extensions: string[]
28
- ignore: string[]
29
- }
34
+ emails: SourceMetadata
35
+ documents?: SourceMetadata
30
36
  standalone: boolean
31
37
  outDir: string
32
38
  bundleFile: string
33
39
  }
34
40
 
41
+ type Source = {
42
+ path: string
43
+ extensions: string[]
44
+ ignore: string[]
45
+ root: string
46
+ globArg: string
47
+ }
48
+
35
49
  const DEFAULT_IGNORE = ["**/_*", "**/_*/**"]
36
- const DEFAULT_EXTENSIONS = [".tsx", ".jsx"]
50
+ const DEFAULT_EMAIL_PATH = "app/javascript/emails"
51
+ const DEFAULT_EMAIL_EXTENSIONS = [".tsx", ".jsx"]
52
+ const DEFAULT_DOCUMENT_PATH = "app/javascript/documents"
53
+ const DEFAULT_DOCUMENT_EXTENSIONS = [".ts", ".tsx"]
37
54
 
38
55
  const VIRTUAL_SERVER = "virtual:react-email-rails/server"
39
56
  const VIRTUAL_MAIN = "virtual:react-email-rails/main"
@@ -48,16 +65,19 @@ const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
48
65
  const OUT_DIR = "tmp/react-email-rails"
49
66
  const BUNDLE_FILE = "emails.js"
50
67
 
51
- export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
52
- const emails =
53
- typeof options.emails === "string" ? { path: options.emails } : (options.emails ?? {})
54
- const path = (emails.path ?? "app/javascript/emails").replace(/^\/|\/$/g, "")
68
+ function normalizeSource(
69
+ option: EmailsOption | undefined,
70
+ defaultPath: string,
71
+ defaultExtensions: string[],
72
+ ): Source {
73
+ const source = typeof option === "string" ? { path: option } : (option ?? {})
74
+ const path = (source.path ?? defaultPath).replace(/^\/|\/$/g, "")
55
75
  const rawExtensions =
56
- emails.extension === undefined
57
- ? DEFAULT_EXTENSIONS
58
- : Array.isArray(emails.extension)
59
- ? emails.extension
60
- : [emails.extension]
76
+ source.extension === undefined
77
+ ? defaultExtensions
78
+ : Array.isArray(source.extension)
79
+ ? source.extension
80
+ : [source.extension]
61
81
  const extensions = rawExtensions
62
82
  .map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
63
83
  .map((extension, index) => ({ extension, index }))
@@ -65,20 +85,34 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
65
85
  (left, right) => right.extension.length - left.extension.length || left.index - right.index,
66
86
  )
67
87
  .map(({ extension }) => extension)
68
- const standalone = options.standalone ?? true
69
88
 
70
89
  const root = `/${path}/`
71
90
  const pattern =
72
91
  extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`
73
92
  const ignore =
74
- emails.ignore === undefined
93
+ source.ignore === undefined
75
94
  ? DEFAULT_IGNORE
76
- : Array.isArray(emails.ignore)
77
- ? emails.ignore
78
- : [emails.ignore]
95
+ : Array.isArray(source.ignore)
96
+ ? source.ignore
97
+ : [source.ignore]
79
98
  const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)]
80
99
  const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns)
81
100
 
101
+ return { path, extensions, ignore, root, globArg }
102
+ }
103
+
104
+ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
105
+ const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS)
106
+ const documentSource =
107
+ options.documents === undefined || options.documents === false
108
+ ? null
109
+ : normalizeSource(
110
+ options.documents === true ? undefined : options.documents,
111
+ DEFAULT_DOCUMENT_PATH,
112
+ DEFAULT_DOCUMENT_EXTENSIONS,
113
+ )
114
+ const standalone = options.standalone ?? true
115
+
82
116
  const plugin: Plugin = {
83
117
  name: "react-email-rails",
84
118
 
@@ -94,17 +128,38 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
94
128
  filter: { id: VIRTUAL_MODULE_PATTERN },
95
129
  handler(id) {
96
130
  if (id === RESOLVED_SERVER) {
97
- return [
98
- `import { serve, toComponentName } from "react-email-rails/runtime"`,
99
- `const modules = import.meta.glob(${globArg})`,
100
- `const extensions = ${JSON.stringify(extensions)}`,
131
+ const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`]
132
+
133
+ // Imported only here, so the editor stays out of the email build graph when off.
134
+ if (documentSource)
135
+ lines.push(`import { composeDocument } from "react-email-rails/document"`)
136
+
137
+ lines.push(
138
+ `const modules = import.meta.glob(${emailSource.globArg})`,
139
+ `const extensions = ${JSON.stringify(emailSource.extensions)}`,
101
140
  `const registry = Object.create(null)`,
102
141
  `for (const path in modules) {`,
103
142
  ` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
104
- ` registry[toComponentName(path, ${JSON.stringify(root)}, extension)] = modules[path]`,
143
+ ` registry[toComponentName(path, ${JSON.stringify(emailSource.root)}, extension)] = modules[path]`,
105
144
  `}`,
106
- `export const run = () => serve(registry)`,
107
- ].join("\n")
145
+ )
146
+
147
+ if (documentSource) {
148
+ lines.push(
149
+ `const documentModules = import.meta.glob(${documentSource.globArg})`,
150
+ `const documentExtensions = ${JSON.stringify(documentSource.extensions)}`,
151
+ `const documentRegistry = Object.create(null)`,
152
+ `for (const path in documentModules) {`,
153
+ ` const extension = documentExtensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
154
+ ` documentRegistry[toComponentName(path, ${JSON.stringify(documentSource.root)}, extension)] = documentModules[path]`,
155
+ `}`,
156
+ `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument })`,
157
+ )
158
+ } else {
159
+ lines.push(`export const run = () => serve(registry)`)
160
+ }
161
+
162
+ return lines.join("\n")
108
163
  }
109
164
 
110
165
  if (id === RESOLVED_MAIN) {
@@ -142,10 +197,17 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
142
197
 
143
198
  const metadata: PluginMetadata = {
144
199
  emails: {
145
- path,
146
- extensions,
147
- ignore,
200
+ path: emailSource.path,
201
+ extensions: emailSource.extensions,
202
+ ignore: emailSource.ignore,
148
203
  },
204
+ ...(documentSource && {
205
+ documents: {
206
+ path: documentSource.path,
207
+ extensions: documentSource.extensions,
208
+ ignore: documentSource.ignore,
209
+ },
210
+ }),
149
211
  standalone,
150
212
  outDir: OUT_DIR,
151
213
  bundleFile: BUNDLE_FILE,
@@ -169,5 +231,6 @@ export type {
169
231
  EmailRenderOptions,
170
232
  RenderedEmail,
171
233
  RenderRequest,
234
+ RenderResult,
172
235
  } from "./runtime.js"
173
236
  export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js"
package/src/runtime.ts CHANGED
@@ -25,6 +25,28 @@ export type RenderedEmail = {
25
25
  text: string
26
26
  }
27
27
 
28
+ export type RenderDocumentRequest = {
29
+ kind: "document"
30
+ type: string
31
+ document: unknown
32
+ context?: unknown
33
+ preview?: string | null
34
+ }
35
+
36
+ // A document node type that rendered to nothing, with how many times it occurred.
37
+ export type DroppedNode = { type: string; count: number }
38
+
39
+ // A render result plus any non-fatal warnings (document nodes dropped because no
40
+ // extension rendered them). Component renders never carry warnings.
41
+ export type RenderResult = RenderedEmail & { warnings?: DroppedNode[] }
42
+
43
+ // Injected by the generated server module when documents are enabled, so `serve`
44
+ // renders documents without importing the editor module or its peer types.
45
+ export type DocumentSupport<Registry = unknown> = {
46
+ registry: Registry
47
+ compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>
48
+ }
49
+
28
50
  type ProtocolMetadata = {
29
51
  protocolVersion: number
30
52
  packageVersion: string
@@ -57,9 +79,38 @@ export async function renderEmail(
57
79
  }
58
80
  }
59
81
 
60
- export async function serve(registry: EmailRegistry): Promise<void> {
82
+ function isDocumentRequest(request: unknown): request is RenderDocumentRequest {
83
+ return (
84
+ request !== null &&
85
+ typeof request === "object" &&
86
+ (request as { kind?: unknown }).kind === "document"
87
+ )
88
+ }
89
+
90
+ function isHealthRequest(request: unknown): request is HealthRequest {
91
+ return request !== null && typeof request === "object" && "health" in request
92
+ }
93
+
94
+ // Requests without a document kind are component renders, preserving the email path.
95
+ async function renderRequest<Registry>(
96
+ request: RenderRequest | RenderDocumentRequest,
97
+ registry: EmailRegistry,
98
+ documents: DocumentSupport<Registry> | null,
99
+ ): Promise<RenderResult> {
100
+ if (isDocumentRequest(request)) {
101
+ if (!documents) throw new Error("React email document rendering is not enabled")
102
+ return documents.compose(request, documents.registry)
103
+ }
104
+
105
+ return renderEmail(request, registry)
106
+ }
107
+
108
+ export async function serve<Registry = unknown>(
109
+ registry: EmailRegistry,
110
+ documents: DocumentSupport<Registry> | null = null,
111
+ ): Promise<void> {
61
112
  if (process.argv.includes("--persistent")) {
62
- await servePersistent(registry, isolateStdout())
113
+ await servePersistent(registry, documents, isolateStdout())
63
114
  return
64
115
  }
65
116
 
@@ -70,8 +121,13 @@ export async function serve(registry: EmailRegistry): Promise<void> {
70
121
 
71
122
  const write = isolateStdout()
72
123
  try {
73
- const request = JSON.parse(await readStdin()) as RenderRequest
74
- write(JSON.stringify({ ...(await renderEmail(request, registry)), ...protocolMetadata() }))
124
+ const request = JSON.parse(await readStdin()) as RenderRequest | RenderDocumentRequest
125
+ write(
126
+ JSON.stringify({
127
+ ...(await renderRequest(request, registry, documents)),
128
+ ...protocolMetadata(),
129
+ }),
130
+ )
75
131
  } catch (error) {
76
132
  process.stderr.write(error instanceof Error ? error.message : "React Email render failed")
77
133
  process.exitCode = 1
@@ -101,8 +157,9 @@ function readStdin(): Promise<string> {
101
157
  })
102
158
  }
103
159
 
104
- async function servePersistent(
160
+ async function servePersistent<Registry>(
105
161
  registry: EmailRegistry,
162
+ documents: DocumentSupport<Registry> | null,
106
163
  write: (chunk: string) => boolean,
107
164
  ): Promise<void> {
108
165
  process.stdin.setEncoding("utf8")
@@ -116,26 +173,27 @@ async function servePersistent(
116
173
  const line = pending.slice(0, separator)
117
174
  pending = pending.slice(separator + 1)
118
175
 
119
- if (line.trim()) await writePersistentResponse(line, registry, write)
176
+ if (line.trim()) await writePersistentResponse(line, registry, documents, write)
120
177
  separator = pending.indexOf("\n")
121
178
  }
122
179
  }
123
180
  }
124
181
 
125
- async function writePersistentResponse(
182
+ async function writePersistentResponse<Registry>(
126
183
  line: string,
127
184
  registry: EmailRegistry,
185
+ documents: DocumentSupport<Registry> | null,
128
186
  write: (chunk: string) => boolean,
129
187
  ): Promise<void> {
130
188
  try {
131
- const request = JSON.parse(line) as RenderRequest | HealthRequest
132
- if ("health" in request) {
189
+ const request = JSON.parse(line) as RenderRequest | RenderDocumentRequest | HealthRequest
190
+ if (isHealthRequest(request)) {
133
191
  write(`${JSON.stringify(okResponse())}\n`)
134
192
  return
135
193
  }
136
194
 
137
195
  write(
138
- `${JSON.stringify({ ok: true, ...(await renderEmail(request, registry)), ...protocolMetadata() })}\n`,
196
+ `${JSON.stringify({ ok: true, ...(await renderRequest(request, registry, documents)), ...protocolMetadata() })}\n`,
139
197
  )
140
198
  } catch (error) {
141
199
  write(
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.1.3"
2
- export const RENDER_PROTOCOL_VERSION = 1
1
+ export const VERSION = "0.2.0"
2
+ export const RENDER_PROTOCOL_VERSION = 2