react-email-rails 0.1.3 → 0.3.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/dist/document.d.ts +27 -0
- package/dist/document.js +96 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +76 -29
- package/dist/runtime.d.ts +29 -1
- package/dist/runtime.js +37 -8
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +28 -1
- package/src/document.ts +164 -0
- package/src/index.ts +127 -31
- package/src/runtime.ts +97 -10
- package/src/version.ts +2 -2
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Extensions } from "@tiptap/core";
|
|
2
|
+
import type { DroppedNode, ParseResult, 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 type ParseDocumentRequest = {
|
|
19
|
+
kind: "parse";
|
|
20
|
+
type: string;
|
|
21
|
+
html: string;
|
|
22
|
+
context?: unknown;
|
|
23
|
+
};
|
|
24
|
+
type GenerateJSON = (html: string, extensions: Extensions) => unknown;
|
|
25
|
+
export declare function composeDocument(request: RenderDocumentRequest, registry: DocumentRegistry): Promise<RenderResult>;
|
|
26
|
+
export declare function parseDocument(request: ParseDocumentRequest, registry: DocumentRegistry, generateJSON?: GenerateJSON): Promise<ParseResult>;
|
|
27
|
+
export declare function createParseDocument(generateJSON: GenerateJSON): (request: ParseDocumentRequest, registry: DocumentRegistry) => Promise<ParseResult>;
|
package/dist/document.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
async function resolveRenderer(type, registry) {
|
|
39
|
+
const loader = registry[type];
|
|
40
|
+
if (!loader)
|
|
41
|
+
throw new Error(`React email document renderer not found: ${type}`);
|
|
42
|
+
const renderer = typeof loader === "function" ? await loader() : loader;
|
|
43
|
+
if (typeof renderer.buildExtensions !== "function") {
|
|
44
|
+
throw new Error(`React email document renderer must export a buildExtensions function: ${type}`);
|
|
45
|
+
}
|
|
46
|
+
return renderer;
|
|
47
|
+
}
|
|
48
|
+
async function loadGenerateJSON() {
|
|
49
|
+
try {
|
|
50
|
+
const mod = (await import(/* @vite-ignore */ "@tiptap/html"));
|
|
51
|
+
if (typeof mod.generateJSON === "function")
|
|
52
|
+
return mod.generateJSON;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw new Error(`@tiptap/html and happy-dom are required to parse HTML documents; install both packages before calling parse (${error instanceof Error ? error.message : "module load failed"})`);
|
|
56
|
+
}
|
|
57
|
+
throw new Error("@tiptap/html is missing the expected generateJSON export; check the installed version");
|
|
58
|
+
}
|
|
59
|
+
export async function composeDocument(request, registry) {
|
|
60
|
+
// Fail legibly if the optional editor peers are present but their shape shifted.
|
|
61
|
+
if (typeof composeReactEmail !== "function" ||
|
|
62
|
+
typeof resolveExtensions !== "function" ||
|
|
63
|
+
typeof getSchema !== "function") {
|
|
64
|
+
throw new Error("@react-email/editor or @tiptap/core is missing expected exports (composeReactEmail/resolveExtensions/getSchema); check the installed versions");
|
|
65
|
+
}
|
|
66
|
+
const renderer = await resolveRenderer(request.type, registry);
|
|
67
|
+
const document = renderer.transformDocument?.(request.document, request.context) ?? request.document;
|
|
68
|
+
const extensions = resolveExtensions(renderer.buildExtensions(request.context));
|
|
69
|
+
const schema = getSchema(extensions);
|
|
70
|
+
const warnings = collectDroppedNodes(document, extensions);
|
|
71
|
+
// The minimal editor composeReactEmail reads, built headless (no DOM, no view).
|
|
72
|
+
// state.doc is required: EmailTheming finds the globalContent theme node through it.
|
|
73
|
+
const editor = {
|
|
74
|
+
getJSON: () => document,
|
|
75
|
+
extensionManager: { extensions },
|
|
76
|
+
schema,
|
|
77
|
+
state: { doc: schema.nodeFromJSON(document) },
|
|
78
|
+
};
|
|
79
|
+
// composeReactEmail takes `preview?: string`; omit it rather than pass null.
|
|
80
|
+
const preview = request.preview ?? renderer.getPreview?.(request.context) ?? null;
|
|
81
|
+
const params = preview === null ? { editor } : { editor, preview };
|
|
82
|
+
const { html, text } = await composeReactEmail(params);
|
|
83
|
+
return warnings.length > 0 ? { html, text, warnings } : { html, text };
|
|
84
|
+
}
|
|
85
|
+
export async function parseDocument(request, registry, generateJSON) {
|
|
86
|
+
const parseHTML = generateJSON ?? (await loadGenerateJSON());
|
|
87
|
+
const renderer = await resolveRenderer(request.type, registry);
|
|
88
|
+
const extensions = resolveExtensions(renderer.buildExtensions(request.context));
|
|
89
|
+
const schema = getSchema(extensions);
|
|
90
|
+
const parsed = parseHTML(request.html, extensions);
|
|
91
|
+
const document = schema.nodeFromJSON(parsed).toJSON();
|
|
92
|
+
return { document };
|
|
93
|
+
}
|
|
94
|
+
export function createParseDocument(generateJSON) {
|
|
95
|
+
return (request, registry) => parseDocument(request, registry, generateJSON);
|
|
96
|
+
}
|
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,9 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
const DEFAULT_IGNORE = ["**/_*", "**/_*/**"];
|
|
2
|
-
const
|
|
3
|
+
const DEFAULT_EMAIL_PATH = "app/javascript/emails";
|
|
4
|
+
const DEFAULT_EMAIL_EXTENSIONS = [".tsx", ".jsx"];
|
|
5
|
+
const DEFAULT_DOCUMENT_PATH = "app/javascript/documents";
|
|
6
|
+
const DEFAULT_DOCUMENT_EXTENSIONS = [".ts", ".tsx"];
|
|
3
7
|
const VIRTUAL_SERVER = "virtual:react-email-rails/server";
|
|
4
8
|
const VIRTUAL_MAIN = "virtual:react-email-rails/main";
|
|
5
9
|
const RESOLVED_SERVER = `\0${VIRTUAL_SERVER}`;
|
|
@@ -11,29 +15,57 @@ const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
|
|
|
11
15
|
const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite");
|
|
12
16
|
const OUT_DIR = "tmp/react-email-rails";
|
|
13
17
|
const BUNDLE_FILE = "emails.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
// happy-dom (pulled in by @tiptap/html when parsing) depends on `ws`, which guards
|
|
20
|
+
// optional native-addon requires behind these env flags. A standalone (noExternal)
|
|
21
|
+
// build would otherwise try to bundle the uninstalled `bufferutil`/`utf-8-validate`
|
|
22
|
+
// and fail at load. Setting the flags lets Rollup tree-shake those require branches
|
|
23
|
+
// away; ws uses its pure-JS implementation, which is all the HTML parser needs.
|
|
24
|
+
const WS_NATIVE_ADDON_OPT_OUT = {
|
|
25
|
+
"process.env.WS_NO_BUFFER_UTIL": "'1'",
|
|
26
|
+
"process.env.WS_NO_UTF_8_VALIDATE": "'1'",
|
|
27
|
+
};
|
|
28
|
+
function normalizeSource(option, defaultPath, defaultExtensions) {
|
|
29
|
+
const source = typeof option === "string" ? { path: option } : (option ?? {});
|
|
30
|
+
const path = (source.path ?? defaultPath).replace(/^\/|\/$/g, "");
|
|
31
|
+
const rawExtensions = source.extension === undefined
|
|
32
|
+
? defaultExtensions
|
|
33
|
+
: Array.isArray(source.extension)
|
|
34
|
+
? source.extension
|
|
35
|
+
: [source.extension];
|
|
22
36
|
const extensions = rawExtensions
|
|
23
37
|
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
|
|
24
38
|
.map((extension, index) => ({ extension, index }))
|
|
25
39
|
.sort((left, right) => right.extension.length - left.extension.length || left.index - right.index)
|
|
26
40
|
.map(({ extension }) => extension);
|
|
27
|
-
const standalone = options.standalone ?? true;
|
|
28
41
|
const root = `/${path}/`;
|
|
29
42
|
const pattern = extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`;
|
|
30
|
-
const ignore =
|
|
43
|
+
const ignore = source.ignore === undefined
|
|
31
44
|
? DEFAULT_IGNORE
|
|
32
|
-
: Array.isArray(
|
|
33
|
-
?
|
|
34
|
-
: [
|
|
45
|
+
: Array.isArray(source.ignore)
|
|
46
|
+
? source.ignore
|
|
47
|
+
: [source.ignore];
|
|
35
48
|
const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)];
|
|
36
49
|
const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns);
|
|
50
|
+
return { path, extensions, ignore, root, globArg };
|
|
51
|
+
}
|
|
52
|
+
function optionalPeersAvailable(specifiers) {
|
|
53
|
+
return specifiers.every((specifier) => {
|
|
54
|
+
try {
|
|
55
|
+
require.resolve(specifier);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function reactEmailRails(options = {}) {
|
|
64
|
+
const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS);
|
|
65
|
+
const documentSource = options.documents === undefined || options.documents === false
|
|
66
|
+
? null
|
|
67
|
+
: normalizeSource(options.documents === true ? undefined : options.documents, DEFAULT_DOCUMENT_PATH, DEFAULT_DOCUMENT_EXTENSIONS);
|
|
68
|
+
const standalone = options.standalone ?? true;
|
|
37
69
|
const plugin = {
|
|
38
70
|
name: "react-email-rails",
|
|
39
71
|
resolveId: {
|
|
@@ -49,17 +81,23 @@ export function reactEmailRails(options = {}) {
|
|
|
49
81
|
filter: { id: VIRTUAL_MODULE_PATTERN },
|
|
50
82
|
handler(id) {
|
|
51
83
|
if (id === RESOLVED_SERVER) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`];
|
|
85
|
+
const parserPeersAvailable = documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"]);
|
|
86
|
+
if (documentSource) {
|
|
87
|
+
lines.push(parserPeersAvailable
|
|
88
|
+
? `import { composeDocument, createParseDocument } from "react-email-rails/document"`
|
|
89
|
+
: `import { composeDocument, parseDocument } from "react-email-rails/document"`);
|
|
90
|
+
if (parserPeersAvailable)
|
|
91
|
+
lines.push(`import { generateJSON } from "@tiptap/html"`);
|
|
92
|
+
}
|
|
93
|
+
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]`, `}`);
|
|
94
|
+
if (documentSource) {
|
|
95
|
+
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, parse: ${parserPeersAvailable ? "createParseDocument(generateJSON)" : "parseDocument"} })`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
lines.push(`export const run = () => serve(registry)`);
|
|
99
|
+
}
|
|
100
|
+
return lines.join("\n");
|
|
63
101
|
}
|
|
64
102
|
if (id === RESOLVED_MAIN) {
|
|
65
103
|
return `import { run } from ${JSON.stringify(VIRTUAL_SERVER)}\nrun()\n`;
|
|
@@ -77,7 +115,9 @@ export function reactEmailRails(options = {}) {
|
|
|
77
115
|
return {
|
|
78
116
|
environments: {
|
|
79
117
|
[EMAIL_ENVIRONMENT]: {
|
|
80
|
-
...(standalone && env.command === "build"
|
|
118
|
+
...(standalone && env.command === "build"
|
|
119
|
+
? { resolve: { noExternal: true }, define: WS_NATIVE_ADDON_OPT_OUT }
|
|
120
|
+
: {}),
|
|
81
121
|
build: {
|
|
82
122
|
ssr: true,
|
|
83
123
|
outDir: OUT_DIR,
|
|
@@ -94,10 +134,17 @@ export function reactEmailRails(options = {}) {
|
|
|
94
134
|
};
|
|
95
135
|
const metadata = {
|
|
96
136
|
emails: {
|
|
97
|
-
path,
|
|
98
|
-
extensions,
|
|
99
|
-
ignore,
|
|
137
|
+
path: emailSource.path,
|
|
138
|
+
extensions: emailSource.extensions,
|
|
139
|
+
ignore: emailSource.ignore,
|
|
100
140
|
},
|
|
141
|
+
...(documentSource && {
|
|
142
|
+
documents: {
|
|
143
|
+
path: documentSource.path,
|
|
144
|
+
extensions: documentSource.extensions,
|
|
145
|
+
ignore: documentSource.ignore,
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
101
148
|
standalone,
|
|
102
149
|
outDir: OUT_DIR,
|
|
103
150
|
bundleFile: BUNDLE_FILE,
|
package/dist/runtime.d.ts
CHANGED
|
@@ -17,10 +17,38 @@ 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 ParseDocumentRequest = {
|
|
28
|
+
kind: "parse";
|
|
29
|
+
type: string;
|
|
30
|
+
html: string;
|
|
31
|
+
context?: unknown;
|
|
32
|
+
};
|
|
33
|
+
export type DroppedNode = {
|
|
34
|
+
type: string;
|
|
35
|
+
count: number;
|
|
36
|
+
};
|
|
37
|
+
export type RenderResult = RenderedEmail & {
|
|
38
|
+
warnings?: DroppedNode[];
|
|
39
|
+
};
|
|
40
|
+
export type ParseResult = {
|
|
41
|
+
document: unknown;
|
|
42
|
+
};
|
|
43
|
+
export type DocumentSupport<Registry = unknown> = {
|
|
44
|
+
registry: Registry;
|
|
45
|
+
compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>;
|
|
46
|
+
parse: (request: ParseDocumentRequest, registry: Registry) => Promise<ParseResult>;
|
|
47
|
+
};
|
|
20
48
|
export type EmailRenderOptions = {
|
|
21
49
|
html?: ReactEmailRenderOptions;
|
|
22
50
|
text?: ReactEmailRenderOptions;
|
|
23
51
|
};
|
|
24
52
|
export declare function toComponentName(globPath: string, root: string, extension: string): string;
|
|
25
53
|
export declare function renderEmail(request: RenderRequest, registry: EmailRegistry): Promise<RenderedEmail>;
|
|
26
|
-
export declare function serve(registry: EmailRegistry): Promise<void>;
|
|
54
|
+
export declare function serve<Registry = unknown>(registry: EmailRegistry, documents?: DocumentSupport<Registry> | null): Promise<void>;
|
package/dist/runtime.js
CHANGED
|
@@ -17,9 +17,35 @@ export async function renderEmail(request, registry) {
|
|
|
17
17
|
text: await render(element, { ...request.renderOptions?.text, plainText: true }),
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
function isDocumentRequest(request) {
|
|
21
|
+
return (request !== null &&
|
|
22
|
+
typeof request === "object" &&
|
|
23
|
+
request.kind === "document");
|
|
24
|
+
}
|
|
25
|
+
function isParseRequest(request) {
|
|
26
|
+
return (request !== null &&
|
|
27
|
+
typeof request === "object" &&
|
|
28
|
+
request.kind === "parse");
|
|
29
|
+
}
|
|
30
|
+
function isHealthRequest(request) {
|
|
31
|
+
return request !== null && typeof request === "object" && "health" in request;
|
|
32
|
+
}
|
|
33
|
+
async function renderRequest(request, registry, documents) {
|
|
34
|
+
if (isDocumentRequest(request)) {
|
|
35
|
+
if (!documents)
|
|
36
|
+
throw new Error("React email document rendering is not enabled");
|
|
37
|
+
return documents.compose(request, documents.registry);
|
|
38
|
+
}
|
|
39
|
+
if (isParseRequest(request)) {
|
|
40
|
+
if (!documents)
|
|
41
|
+
throw new Error("React email document rendering is not enabled");
|
|
42
|
+
return documents.parse(request, documents.registry);
|
|
43
|
+
}
|
|
44
|
+
return renderEmail(request, registry);
|
|
45
|
+
}
|
|
46
|
+
export async function serve(registry, documents = null) {
|
|
21
47
|
if (process.argv.includes("--persistent")) {
|
|
22
|
-
await servePersistent(registry, isolateStdout());
|
|
48
|
+
await servePersistent(registry, documents, isolateStdout());
|
|
23
49
|
return;
|
|
24
50
|
}
|
|
25
51
|
if (process.argv.includes("--health")) {
|
|
@@ -29,7 +55,10 @@ export async function serve(registry) {
|
|
|
29
55
|
const write = isolateStdout();
|
|
30
56
|
try {
|
|
31
57
|
const request = JSON.parse(await readStdin());
|
|
32
|
-
write(JSON.stringify({
|
|
58
|
+
write(JSON.stringify({
|
|
59
|
+
...(await renderRequest(request, registry, documents)),
|
|
60
|
+
...protocolMetadata(),
|
|
61
|
+
}));
|
|
33
62
|
}
|
|
34
63
|
catch (error) {
|
|
35
64
|
process.stderr.write(error instanceof Error ? error.message : "React Email render failed");
|
|
@@ -56,7 +85,7 @@ function readStdin() {
|
|
|
56
85
|
process.stdin.on("error", reject);
|
|
57
86
|
});
|
|
58
87
|
}
|
|
59
|
-
async function servePersistent(registry, write) {
|
|
88
|
+
async function servePersistent(registry, documents, write) {
|
|
60
89
|
process.stdin.setEncoding("utf8");
|
|
61
90
|
let pending = "";
|
|
62
91
|
for await (const chunk of process.stdin) {
|
|
@@ -66,19 +95,19 @@ async function servePersistent(registry, write) {
|
|
|
66
95
|
const line = pending.slice(0, separator);
|
|
67
96
|
pending = pending.slice(separator + 1);
|
|
68
97
|
if (line.trim())
|
|
69
|
-
await writePersistentResponse(line, registry, write);
|
|
98
|
+
await writePersistentResponse(line, registry, documents, write);
|
|
70
99
|
separator = pending.indexOf("\n");
|
|
71
100
|
}
|
|
72
101
|
}
|
|
73
102
|
}
|
|
74
|
-
async function writePersistentResponse(line, registry, write) {
|
|
103
|
+
async function writePersistentResponse(line, registry, documents, write) {
|
|
75
104
|
try {
|
|
76
105
|
const request = JSON.parse(line);
|
|
77
|
-
if (
|
|
106
|
+
if (isHealthRequest(request)) {
|
|
78
107
|
write(`${JSON.stringify(okResponse())}\n`);
|
|
79
108
|
return;
|
|
80
109
|
}
|
|
81
|
-
write(`${JSON.stringify({ ok: true, ...(await
|
|
110
|
+
write(`${JSON.stringify({ ok: true, ...(await renderRequest(request, registry, documents)), ...protocolMetadata() })}\n`);
|
|
82
111
|
}
|
|
83
112
|
catch (error) {
|
|
84
113
|
write(`${JSON.stringify({
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
2
|
-
export declare const RENDER_PROTOCOL_VERSION =
|
|
1
|
+
export declare const VERSION = "0.3.0";
|
|
2
|
+
export declare const RENDER_PROTOCOL_VERSION = 3;
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
2
|
-
export const RENDER_PROTOCOL_VERSION =
|
|
1
|
+
export const VERSION = "0.3.0";
|
|
2
|
+
export const RENDER_PROTOCOL_VERSION = 3;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-email-rails",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,15 +61,38 @@
|
|
|
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",
|
|
67
|
+
"@tiptap/html": "^3",
|
|
68
|
+
"happy-dom": "^20.8.9",
|
|
61
69
|
"react": "^18.0 || ^19.0",
|
|
62
70
|
"vite": "^7.0.0 || ^8.0.0"
|
|
63
71
|
},
|
|
72
|
+
"peerDependenciesMeta": {
|
|
73
|
+
"@react-email/editor": {
|
|
74
|
+
"optional": true
|
|
75
|
+
},
|
|
76
|
+
"@tiptap/core": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"@tiptap/html": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"happy-dom": {
|
|
83
|
+
"optional": true
|
|
84
|
+
}
|
|
85
|
+
},
|
|
64
86
|
"devDependencies": {
|
|
87
|
+
"@react-email/editor": "^1.5.3",
|
|
65
88
|
"@react-email/render": "^2.0.8",
|
|
89
|
+
"@tiptap/core": "^3",
|
|
90
|
+
"@tiptap/html": "^3",
|
|
91
|
+
"@tiptap/pm": "^3",
|
|
66
92
|
"@types/node": "^25.9.1",
|
|
67
93
|
"@types/react": "^19.2.15",
|
|
68
94
|
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
|
95
|
+
"happy-dom": "^20.8.9",
|
|
69
96
|
"oxfmt": "^0.52.0",
|
|
70
97
|
"oxlint": "^1.67.0",
|
|
71
98
|
"oxlint-tsgolint": "^0.23.0",
|
package/src/document.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
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, ParseResult, 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 type ParseDocumentRequest = {
|
|
65
|
+
kind: "parse"
|
|
66
|
+
type: string
|
|
67
|
+
html: string
|
|
68
|
+
context?: unknown
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type GenerateJSON = (html: string, extensions: Extensions) => unknown
|
|
72
|
+
|
|
73
|
+
async function resolveRenderer(
|
|
74
|
+
type: string,
|
|
75
|
+
registry: DocumentRegistry,
|
|
76
|
+
): Promise<DocumentRenderer> {
|
|
77
|
+
const loader = registry[type]
|
|
78
|
+
if (!loader) throw new Error(`React email document renderer not found: ${type}`)
|
|
79
|
+
|
|
80
|
+
const renderer = typeof loader === "function" ? await loader() : loader
|
|
81
|
+
if (typeof renderer.buildExtensions !== "function") {
|
|
82
|
+
throw new Error(`React email document renderer must export a buildExtensions function: ${type}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return renderer
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function loadGenerateJSON(): Promise<GenerateJSON> {
|
|
89
|
+
try {
|
|
90
|
+
const mod = (await import(/* @vite-ignore */ "@tiptap/html")) as {
|
|
91
|
+
generateJSON?: unknown
|
|
92
|
+
}
|
|
93
|
+
if (typeof mod.generateJSON === "function") return mod.generateJSON as GenerateJSON
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`@tiptap/html and happy-dom are required to parse HTML documents; install both packages before calling parse (${error instanceof Error ? error.message : "module load failed"})`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new Error(
|
|
101
|
+
"@tiptap/html is missing the expected generateJSON export; check the installed version",
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function composeDocument(
|
|
106
|
+
request: RenderDocumentRequest,
|
|
107
|
+
registry: DocumentRegistry,
|
|
108
|
+
): Promise<RenderResult> {
|
|
109
|
+
// Fail legibly if the optional editor peers are present but their shape shifted.
|
|
110
|
+
if (
|
|
111
|
+
typeof composeReactEmail !== "function" ||
|
|
112
|
+
typeof resolveExtensions !== "function" ||
|
|
113
|
+
typeof getSchema !== "function"
|
|
114
|
+
) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
"@react-email/editor or @tiptap/core is missing expected exports (composeReactEmail/resolveExtensions/getSchema); check the installed versions",
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const renderer = await resolveRenderer(request.type, registry)
|
|
121
|
+
|
|
122
|
+
const document =
|
|
123
|
+
renderer.transformDocument?.(request.document, request.context) ?? request.document
|
|
124
|
+
const extensions = resolveExtensions(renderer.buildExtensions(request.context))
|
|
125
|
+
const schema = getSchema(extensions)
|
|
126
|
+
const warnings = collectDroppedNodes(document, extensions)
|
|
127
|
+
|
|
128
|
+
// The minimal editor composeReactEmail reads, built headless (no DOM, no view).
|
|
129
|
+
// state.doc is required: EmailTheming finds the globalContent theme node through it.
|
|
130
|
+
const editor = {
|
|
131
|
+
getJSON: () => document,
|
|
132
|
+
extensionManager: { extensions },
|
|
133
|
+
schema,
|
|
134
|
+
state: { doc: schema.nodeFromJSON(document) },
|
|
135
|
+
} as unknown as Editor
|
|
136
|
+
|
|
137
|
+
// composeReactEmail takes `preview?: string`; omit it rather than pass null.
|
|
138
|
+
const preview = request.preview ?? renderer.getPreview?.(request.context) ?? null
|
|
139
|
+
const params = preview === null ? { editor } : { editor, preview }
|
|
140
|
+
|
|
141
|
+
const { html, text } = await composeReactEmail(params)
|
|
142
|
+
return warnings.length > 0 ? { html, text, warnings } : { html, text }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function parseDocument(
|
|
146
|
+
request: ParseDocumentRequest,
|
|
147
|
+
registry: DocumentRegistry,
|
|
148
|
+
generateJSON?: GenerateJSON,
|
|
149
|
+
): Promise<ParseResult> {
|
|
150
|
+
const parseHTML = generateJSON ?? (await loadGenerateJSON())
|
|
151
|
+
const renderer = await resolveRenderer(request.type, registry)
|
|
152
|
+
const extensions = resolveExtensions(renderer.buildExtensions(request.context))
|
|
153
|
+
const schema = getSchema(extensions)
|
|
154
|
+
|
|
155
|
+
const parsed = parseHTML(request.html, extensions)
|
|
156
|
+
const document = schema.nodeFromJSON(parsed).toJSON()
|
|
157
|
+
|
|
158
|
+
return { document }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createParseDocument(generateJSON: GenerateJSON) {
|
|
162
|
+
return (request: ParseDocumentRequest, registry: DocumentRegistry): Promise<ParseResult> =>
|
|
163
|
+
parseDocument(request, registry, generateJSON)
|
|
164
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createRequire } from "node:module"
|
|
2
|
+
|
|
1
3
|
import type { ConfigEnv, Plugin, UserConfig } from "vite"
|
|
2
4
|
|
|
3
5
|
export type EmailsOption =
|
|
@@ -10,6 +12,9 @@ export type EmailsOption =
|
|
|
10
12
|
|
|
11
13
|
export type ReactEmailRailsOptions = {
|
|
12
14
|
emails?: EmailsOption
|
|
15
|
+
// Editor document renderers, discovered like emails. Off by default; pass `true`
|
|
16
|
+
// to enable with defaults, or a path/options object to customize discovery.
|
|
17
|
+
documents?: EmailsOption | boolean
|
|
13
18
|
standalone?: boolean
|
|
14
19
|
vite?: ReactEmailRailsViteOptions
|
|
15
20
|
}
|
|
@@ -21,19 +26,33 @@ export type ReactEmailRailsViteOptions = Pick<
|
|
|
21
26
|
oxc?: unknown
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
type SourceMetadata = {
|
|
30
|
+
path: string
|
|
31
|
+
extensions: string[]
|
|
32
|
+
ignore: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
type PluginMetadata = {
|
|
25
|
-
emails:
|
|
26
|
-
|
|
27
|
-
extensions: string[]
|
|
28
|
-
ignore: string[]
|
|
29
|
-
}
|
|
36
|
+
emails: SourceMetadata
|
|
37
|
+
documents?: SourceMetadata
|
|
30
38
|
standalone: boolean
|
|
31
39
|
outDir: string
|
|
32
40
|
bundleFile: string
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
type Source = {
|
|
44
|
+
path: string
|
|
45
|
+
extensions: string[]
|
|
46
|
+
ignore: string[]
|
|
47
|
+
root: string
|
|
48
|
+
globArg: string
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
const DEFAULT_IGNORE = ["**/_*", "**/_*/**"]
|
|
36
|
-
const
|
|
52
|
+
const DEFAULT_EMAIL_PATH = "app/javascript/emails"
|
|
53
|
+
const DEFAULT_EMAIL_EXTENSIONS = [".tsx", ".jsx"]
|
|
54
|
+
const DEFAULT_DOCUMENT_PATH = "app/javascript/documents"
|
|
55
|
+
const DEFAULT_DOCUMENT_EXTENSIONS = [".ts", ".tsx"]
|
|
37
56
|
|
|
38
57
|
const VIRTUAL_SERVER = "virtual:react-email-rails/server"
|
|
39
58
|
const VIRTUAL_MAIN = "virtual:react-email-rails/main"
|
|
@@ -47,17 +66,31 @@ const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
|
|
|
47
66
|
const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
|
|
48
67
|
const OUT_DIR = "tmp/react-email-rails"
|
|
49
68
|
const BUNDLE_FILE = "emails.js"
|
|
69
|
+
const require = createRequire(import.meta.url)
|
|
50
70
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
// happy-dom (pulled in by @tiptap/html when parsing) depends on `ws`, which guards
|
|
72
|
+
// optional native-addon requires behind these env flags. A standalone (noExternal)
|
|
73
|
+
// build would otherwise try to bundle the uninstalled `bufferutil`/`utf-8-validate`
|
|
74
|
+
// and fail at load. Setting the flags lets Rollup tree-shake those require branches
|
|
75
|
+
// away; ws uses its pure-JS implementation, which is all the HTML parser needs.
|
|
76
|
+
const WS_NATIVE_ADDON_OPT_OUT = {
|
|
77
|
+
"process.env.WS_NO_BUFFER_UTIL": "'1'",
|
|
78
|
+
"process.env.WS_NO_UTF_8_VALIDATE": "'1'",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeSource(
|
|
82
|
+
option: EmailsOption | undefined,
|
|
83
|
+
defaultPath: string,
|
|
84
|
+
defaultExtensions: string[],
|
|
85
|
+
): Source {
|
|
86
|
+
const source = typeof option === "string" ? { path: option } : (option ?? {})
|
|
87
|
+
const path = (source.path ?? defaultPath).replace(/^\/|\/$/g, "")
|
|
55
88
|
const rawExtensions =
|
|
56
|
-
|
|
57
|
-
?
|
|
58
|
-
: Array.isArray(
|
|
59
|
-
?
|
|
60
|
-
: [
|
|
89
|
+
source.extension === undefined
|
|
90
|
+
? defaultExtensions
|
|
91
|
+
: Array.isArray(source.extension)
|
|
92
|
+
? source.extension
|
|
93
|
+
: [source.extension]
|
|
61
94
|
const extensions = rawExtensions
|
|
62
95
|
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
|
|
63
96
|
.map((extension, index) => ({ extension, index }))
|
|
@@ -65,20 +98,45 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
65
98
|
(left, right) => right.extension.length - left.extension.length || left.index - right.index,
|
|
66
99
|
)
|
|
67
100
|
.map(({ extension }) => extension)
|
|
68
|
-
const standalone = options.standalone ?? true
|
|
69
101
|
|
|
70
102
|
const root = `/${path}/`
|
|
71
103
|
const pattern =
|
|
72
104
|
extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`
|
|
73
105
|
const ignore =
|
|
74
|
-
|
|
106
|
+
source.ignore === undefined
|
|
75
107
|
? DEFAULT_IGNORE
|
|
76
|
-
: Array.isArray(
|
|
77
|
-
?
|
|
78
|
-
: [
|
|
108
|
+
: Array.isArray(source.ignore)
|
|
109
|
+
? source.ignore
|
|
110
|
+
: [source.ignore]
|
|
79
111
|
const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)]
|
|
80
112
|
const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns)
|
|
81
113
|
|
|
114
|
+
return { path, extensions, ignore, root, globArg }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function optionalPeersAvailable(specifiers: string[]): boolean {
|
|
118
|
+
return specifiers.every((specifier) => {
|
|
119
|
+
try {
|
|
120
|
+
require.resolve(specifier)
|
|
121
|
+
return true
|
|
122
|
+
} catch {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
129
|
+
const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS)
|
|
130
|
+
const documentSource =
|
|
131
|
+
options.documents === undefined || options.documents === false
|
|
132
|
+
? null
|
|
133
|
+
: normalizeSource(
|
|
134
|
+
options.documents === true ? undefined : options.documents,
|
|
135
|
+
DEFAULT_DOCUMENT_PATH,
|
|
136
|
+
DEFAULT_DOCUMENT_EXTENSIONS,
|
|
137
|
+
)
|
|
138
|
+
const standalone = options.standalone ?? true
|
|
139
|
+
|
|
82
140
|
const plugin: Plugin = {
|
|
83
141
|
name: "react-email-rails",
|
|
84
142
|
|
|
@@ -94,17 +152,45 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
94
152
|
filter: { id: VIRTUAL_MODULE_PATTERN },
|
|
95
153
|
handler(id) {
|
|
96
154
|
if (id === RESOLVED_SERVER) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
155
|
+
const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`]
|
|
156
|
+
const parserPeersAvailable =
|
|
157
|
+
documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"])
|
|
158
|
+
|
|
159
|
+
if (documentSource) {
|
|
160
|
+
lines.push(
|
|
161
|
+
parserPeersAvailable
|
|
162
|
+
? `import { composeDocument, createParseDocument } from "react-email-rails/document"`
|
|
163
|
+
: `import { composeDocument, parseDocument } from "react-email-rails/document"`,
|
|
164
|
+
)
|
|
165
|
+
if (parserPeersAvailable) lines.push(`import { generateJSON } from "@tiptap/html"`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
lines.push(
|
|
169
|
+
`const modules = import.meta.glob(${emailSource.globArg})`,
|
|
170
|
+
`const extensions = ${JSON.stringify(emailSource.extensions)}`,
|
|
101
171
|
`const registry = Object.create(null)`,
|
|
102
172
|
`for (const path in modules) {`,
|
|
103
173
|
` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
|
|
104
|
-
` registry[toComponentName(path, ${JSON.stringify(root)}, extension)] = modules[path]`,
|
|
174
|
+
` registry[toComponentName(path, ${JSON.stringify(emailSource.root)}, extension)] = modules[path]`,
|
|
105
175
|
`}`,
|
|
106
|
-
|
|
107
|
-
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if (documentSource) {
|
|
179
|
+
lines.push(
|
|
180
|
+
`const documentModules = import.meta.glob(${documentSource.globArg})`,
|
|
181
|
+
`const documentExtensions = ${JSON.stringify(documentSource.extensions)}`,
|
|
182
|
+
`const documentRegistry = Object.create(null)`,
|
|
183
|
+
`for (const path in documentModules) {`,
|
|
184
|
+
` const extension = documentExtensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
|
|
185
|
+
` documentRegistry[toComponentName(path, ${JSON.stringify(documentSource.root)}, extension)] = documentModules[path]`,
|
|
186
|
+
`}`,
|
|
187
|
+
`export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? "createParseDocument(generateJSON)" : "parseDocument"} })`,
|
|
188
|
+
)
|
|
189
|
+
} else {
|
|
190
|
+
lines.push(`export const run = () => serve(registry)`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return lines.join("\n")
|
|
108
194
|
}
|
|
109
195
|
|
|
110
196
|
if (id === RESOLVED_MAIN) {
|
|
@@ -124,7 +210,9 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
124
210
|
return {
|
|
125
211
|
environments: {
|
|
126
212
|
[EMAIL_ENVIRONMENT]: {
|
|
127
|
-
...(standalone && env.command === "build"
|
|
213
|
+
...(standalone && env.command === "build"
|
|
214
|
+
? { resolve: { noExternal: true }, define: WS_NATIVE_ADDON_OPT_OUT }
|
|
215
|
+
: {}),
|
|
128
216
|
build: {
|
|
129
217
|
ssr: true,
|
|
130
218
|
outDir: OUT_DIR,
|
|
@@ -142,10 +230,17 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
142
230
|
|
|
143
231
|
const metadata: PluginMetadata = {
|
|
144
232
|
emails: {
|
|
145
|
-
path,
|
|
146
|
-
extensions,
|
|
147
|
-
ignore,
|
|
233
|
+
path: emailSource.path,
|
|
234
|
+
extensions: emailSource.extensions,
|
|
235
|
+
ignore: emailSource.ignore,
|
|
148
236
|
},
|
|
237
|
+
...(documentSource && {
|
|
238
|
+
documents: {
|
|
239
|
+
path: documentSource.path,
|
|
240
|
+
extensions: documentSource.extensions,
|
|
241
|
+
ignore: documentSource.ignore,
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
149
244
|
standalone,
|
|
150
245
|
outDir: OUT_DIR,
|
|
151
246
|
bundleFile: BUNDLE_FILE,
|
|
@@ -169,5 +264,6 @@ export type {
|
|
|
169
264
|
EmailRenderOptions,
|
|
170
265
|
RenderedEmail,
|
|
171
266
|
RenderRequest,
|
|
267
|
+
RenderResult,
|
|
172
268
|
} from "./runtime.js"
|
|
173
269
|
export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js"
|
package/src/runtime.ts
CHANGED
|
@@ -25,6 +25,38 @@ 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
|
+
export type ParseDocumentRequest = {
|
|
37
|
+
kind: "parse"
|
|
38
|
+
type: string
|
|
39
|
+
html: string
|
|
40
|
+
context?: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A document node type that rendered to nothing, with how many times it occurred.
|
|
44
|
+
export type DroppedNode = { type: string; count: number }
|
|
45
|
+
|
|
46
|
+
// A render result plus any non-fatal warnings (document nodes dropped because no
|
|
47
|
+
// extension rendered them). Component renders never carry warnings.
|
|
48
|
+
export type RenderResult = RenderedEmail & { warnings?: DroppedNode[] }
|
|
49
|
+
|
|
50
|
+
export type ParseResult = { document: unknown }
|
|
51
|
+
|
|
52
|
+
// Injected by the generated server module when documents are enabled, so `serve`
|
|
53
|
+
// renders documents without importing the editor module or its peer types.
|
|
54
|
+
export type DocumentSupport<Registry = unknown> = {
|
|
55
|
+
registry: Registry
|
|
56
|
+
compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>
|
|
57
|
+
parse: (request: ParseDocumentRequest, registry: Registry) => Promise<ParseResult>
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
type ProtocolMetadata = {
|
|
29
61
|
protocolVersion: number
|
|
30
62
|
packageVersion: string
|
|
@@ -57,9 +89,50 @@ export async function renderEmail(
|
|
|
57
89
|
}
|
|
58
90
|
}
|
|
59
91
|
|
|
60
|
-
|
|
92
|
+
function isDocumentRequest(request: unknown): request is RenderDocumentRequest {
|
|
93
|
+
return (
|
|
94
|
+
request !== null &&
|
|
95
|
+
typeof request === "object" &&
|
|
96
|
+
(request as { kind?: unknown }).kind === "document"
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isParseRequest(request: unknown): request is ParseDocumentRequest {
|
|
101
|
+
return (
|
|
102
|
+
request !== null &&
|
|
103
|
+
typeof request === "object" &&
|
|
104
|
+
(request as { kind?: unknown }).kind === "parse"
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isHealthRequest(request: unknown): request is HealthRequest {
|
|
109
|
+
return request !== null && typeof request === "object" && "health" in request
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function renderRequest<Registry>(
|
|
113
|
+
request: RenderRequest | RenderDocumentRequest | ParseDocumentRequest,
|
|
114
|
+
registry: EmailRegistry,
|
|
115
|
+
documents: DocumentSupport<Registry> | null,
|
|
116
|
+
): Promise<RenderResult | ParseResult> {
|
|
117
|
+
if (isDocumentRequest(request)) {
|
|
118
|
+
if (!documents) throw new Error("React email document rendering is not enabled")
|
|
119
|
+
return documents.compose(request, documents.registry)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isParseRequest(request)) {
|
|
123
|
+
if (!documents) throw new Error("React email document rendering is not enabled")
|
|
124
|
+
return documents.parse(request, documents.registry)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return renderEmail(request, registry)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function serve<Registry = unknown>(
|
|
131
|
+
registry: EmailRegistry,
|
|
132
|
+
documents: DocumentSupport<Registry> | null = null,
|
|
133
|
+
): Promise<void> {
|
|
61
134
|
if (process.argv.includes("--persistent")) {
|
|
62
|
-
await servePersistent(registry, isolateStdout())
|
|
135
|
+
await servePersistent(registry, documents, isolateStdout())
|
|
63
136
|
return
|
|
64
137
|
}
|
|
65
138
|
|
|
@@ -70,8 +143,16 @@ export async function serve(registry: EmailRegistry): Promise<void> {
|
|
|
70
143
|
|
|
71
144
|
const write = isolateStdout()
|
|
72
145
|
try {
|
|
73
|
-
const request = JSON.parse(await readStdin()) as
|
|
74
|
-
|
|
146
|
+
const request = JSON.parse(await readStdin()) as
|
|
147
|
+
| RenderRequest
|
|
148
|
+
| RenderDocumentRequest
|
|
149
|
+
| ParseDocumentRequest
|
|
150
|
+
write(
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
...(await renderRequest(request, registry, documents)),
|
|
153
|
+
...protocolMetadata(),
|
|
154
|
+
}),
|
|
155
|
+
)
|
|
75
156
|
} catch (error) {
|
|
76
157
|
process.stderr.write(error instanceof Error ? error.message : "React Email render failed")
|
|
77
158
|
process.exitCode = 1
|
|
@@ -101,8 +182,9 @@ function readStdin(): Promise<string> {
|
|
|
101
182
|
})
|
|
102
183
|
}
|
|
103
184
|
|
|
104
|
-
async function servePersistent(
|
|
185
|
+
async function servePersistent<Registry>(
|
|
105
186
|
registry: EmailRegistry,
|
|
187
|
+
documents: DocumentSupport<Registry> | null,
|
|
106
188
|
write: (chunk: string) => boolean,
|
|
107
189
|
): Promise<void> {
|
|
108
190
|
process.stdin.setEncoding("utf8")
|
|
@@ -116,26 +198,31 @@ async function servePersistent(
|
|
|
116
198
|
const line = pending.slice(0, separator)
|
|
117
199
|
pending = pending.slice(separator + 1)
|
|
118
200
|
|
|
119
|
-
if (line.trim()) await writePersistentResponse(line, registry, write)
|
|
201
|
+
if (line.trim()) await writePersistentResponse(line, registry, documents, write)
|
|
120
202
|
separator = pending.indexOf("\n")
|
|
121
203
|
}
|
|
122
204
|
}
|
|
123
205
|
}
|
|
124
206
|
|
|
125
|
-
async function writePersistentResponse(
|
|
207
|
+
async function writePersistentResponse<Registry>(
|
|
126
208
|
line: string,
|
|
127
209
|
registry: EmailRegistry,
|
|
210
|
+
documents: DocumentSupport<Registry> | null,
|
|
128
211
|
write: (chunk: string) => boolean,
|
|
129
212
|
): Promise<void> {
|
|
130
213
|
try {
|
|
131
|
-
const request = JSON.parse(line) as
|
|
132
|
-
|
|
214
|
+
const request = JSON.parse(line) as
|
|
215
|
+
| RenderRequest
|
|
216
|
+
| RenderDocumentRequest
|
|
217
|
+
| ParseDocumentRequest
|
|
218
|
+
| HealthRequest
|
|
219
|
+
if (isHealthRequest(request)) {
|
|
133
220
|
write(`${JSON.stringify(okResponse())}\n`)
|
|
134
221
|
return
|
|
135
222
|
}
|
|
136
223
|
|
|
137
224
|
write(
|
|
138
|
-
`${JSON.stringify({ ok: true, ...(await
|
|
225
|
+
`${JSON.stringify({ ok: true, ...(await renderRequest(request, registry, documents)), ...protocolMetadata() })}\n`,
|
|
139
226
|
)
|
|
140
227
|
} catch (error) {
|
|
141
228
|
write(
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
2
|
-
export const RENDER_PROTOCOL_VERSION =
|
|
1
|
+
export const VERSION = "0.3.0"
|
|
2
|
+
export const RENDER_PROTOCOL_VERSION = 3
|