react-email-rails 0.2.0 → 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 +10 -1
- package/dist/document.js +34 -7
- package/dist/index.js +34 -5
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.js +10 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +12 -1
- package/src/document.ts +64 -10
- package/src/index.ts +38 -5
- package/src/runtime.ts +34 -5
- package/src/version.ts +2 -2
package/dist/document.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Extensions } from "@tiptap/core";
|
|
2
|
-
import type { DroppedNode, RenderResult } from "./runtime.js";
|
|
2
|
+
import type { DroppedNode, ParseResult, RenderResult } from "./runtime.js";
|
|
3
3
|
export type { DroppedNode };
|
|
4
4
|
export type DocumentRenderer = {
|
|
5
5
|
buildExtensions: (context: unknown) => Extensions;
|
|
@@ -15,4 +15,13 @@ export type RenderDocumentRequest = {
|
|
|
15
15
|
context?: unknown;
|
|
16
16
|
preview?: string | null;
|
|
17
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;
|
|
18
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
CHANGED
|
@@ -35,6 +35,27 @@ function collectDroppedNodes(document, extensions) {
|
|
|
35
35
|
walk(document.content);
|
|
36
36
|
return [...counts].map(([type, count]) => ({ type, count }));
|
|
37
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
|
+
}
|
|
38
59
|
export async function composeDocument(request, registry) {
|
|
39
60
|
// Fail legibly if the optional editor peers are present but their shape shifted.
|
|
40
61
|
if (typeof composeReactEmail !== "function" ||
|
|
@@ -42,13 +63,7 @@ export async function composeDocument(request, registry) {
|
|
|
42
63
|
typeof getSchema !== "function") {
|
|
43
64
|
throw new Error("@react-email/editor or @tiptap/core is missing expected exports (composeReactEmail/resolveExtensions/getSchema); check the installed versions");
|
|
44
65
|
}
|
|
45
|
-
const
|
|
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
|
-
}
|
|
66
|
+
const renderer = await resolveRenderer(request.type, registry);
|
|
52
67
|
const document = renderer.transformDocument?.(request.document, request.context) ?? request.document;
|
|
53
68
|
const extensions = resolveExtensions(renderer.buildExtensions(request.context));
|
|
54
69
|
const schema = getSchema(extensions);
|
|
@@ -67,3 +82,15 @@ export async function composeDocument(request, registry) {
|
|
|
67
82
|
const { html, text } = await composeReactEmail(params);
|
|
68
83
|
return warnings.length > 0 ? { html, text, warnings } : { html, text };
|
|
69
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.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
const DEFAULT_IGNORE = ["**/_*", "**/_*/**"];
|
|
2
3
|
const DEFAULT_EMAIL_PATH = "app/javascript/emails";
|
|
3
4
|
const DEFAULT_EMAIL_EXTENSIONS = [".tsx", ".jsx"];
|
|
@@ -14,6 +15,16 @@ const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
|
|
|
14
15
|
const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite");
|
|
15
16
|
const OUT_DIR = "tmp/react-email-rails";
|
|
16
17
|
const BUNDLE_FILE = "emails.js";
|
|
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
|
+
};
|
|
17
28
|
function normalizeSource(option, defaultPath, defaultExtensions) {
|
|
18
29
|
const source = typeof option === "string" ? { path: option } : (option ?? {});
|
|
19
30
|
const path = (source.path ?? defaultPath).replace(/^\/|\/$/g, "");
|
|
@@ -38,6 +49,17 @@ function normalizeSource(option, defaultPath, defaultExtensions) {
|
|
|
38
49
|
const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns);
|
|
39
50
|
return { path, extensions, ignore, root, globArg };
|
|
40
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
|
+
}
|
|
41
63
|
export function reactEmailRails(options = {}) {
|
|
42
64
|
const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS);
|
|
43
65
|
const documentSource = options.documents === undefined || options.documents === false
|
|
@@ -60,12 +82,17 @@ export function reactEmailRails(options = {}) {
|
|
|
60
82
|
handler(id) {
|
|
61
83
|
if (id === RESOLVED_SERVER) {
|
|
62
84
|
const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`];
|
|
63
|
-
|
|
64
|
-
if (documentSource)
|
|
65
|
-
lines.push(
|
|
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
|
+
}
|
|
66
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]`, `}`);
|
|
67
94
|
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 })`);
|
|
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"} })`);
|
|
69
96
|
}
|
|
70
97
|
else {
|
|
71
98
|
lines.push(`export const run = () => serve(registry)`);
|
|
@@ -88,7 +115,9 @@ export function reactEmailRails(options = {}) {
|
|
|
88
115
|
return {
|
|
89
116
|
environments: {
|
|
90
117
|
[EMAIL_ENVIRONMENT]: {
|
|
91
|
-
...(standalone && env.command === "build"
|
|
118
|
+
...(standalone && env.command === "build"
|
|
119
|
+
? { resolve: { noExternal: true }, define: WS_NATIVE_ADDON_OPT_OUT }
|
|
120
|
+
: {}),
|
|
92
121
|
build: {
|
|
93
122
|
ssr: true,
|
|
94
123
|
outDir: OUT_DIR,
|
package/dist/runtime.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export type RenderDocumentRequest = {
|
|
|
24
24
|
context?: unknown;
|
|
25
25
|
preview?: string | null;
|
|
26
26
|
};
|
|
27
|
+
export type ParseDocumentRequest = {
|
|
28
|
+
kind: "parse";
|
|
29
|
+
type: string;
|
|
30
|
+
html: string;
|
|
31
|
+
context?: unknown;
|
|
32
|
+
};
|
|
27
33
|
export type DroppedNode = {
|
|
28
34
|
type: string;
|
|
29
35
|
count: number;
|
|
@@ -31,9 +37,13 @@ export type DroppedNode = {
|
|
|
31
37
|
export type RenderResult = RenderedEmail & {
|
|
32
38
|
warnings?: DroppedNode[];
|
|
33
39
|
};
|
|
40
|
+
export type ParseResult = {
|
|
41
|
+
document: unknown;
|
|
42
|
+
};
|
|
34
43
|
export type DocumentSupport<Registry = unknown> = {
|
|
35
44
|
registry: Registry;
|
|
36
45
|
compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>;
|
|
46
|
+
parse: (request: ParseDocumentRequest, registry: Registry) => Promise<ParseResult>;
|
|
37
47
|
};
|
|
38
48
|
export type EmailRenderOptions = {
|
|
39
49
|
html?: ReactEmailRenderOptions;
|
package/dist/runtime.js
CHANGED
|
@@ -22,16 +22,25 @@ function isDocumentRequest(request) {
|
|
|
22
22
|
typeof request === "object" &&
|
|
23
23
|
request.kind === "document");
|
|
24
24
|
}
|
|
25
|
+
function isParseRequest(request) {
|
|
26
|
+
return (request !== null &&
|
|
27
|
+
typeof request === "object" &&
|
|
28
|
+
request.kind === "parse");
|
|
29
|
+
}
|
|
25
30
|
function isHealthRequest(request) {
|
|
26
31
|
return request !== null && typeof request === "object" && "health" in request;
|
|
27
32
|
}
|
|
28
|
-
// Requests without a document kind are component renders, preserving the email path.
|
|
29
33
|
async function renderRequest(request, registry, documents) {
|
|
30
34
|
if (isDocumentRequest(request)) {
|
|
31
35
|
if (!documents)
|
|
32
36
|
throw new Error("React email document rendering is not enabled");
|
|
33
37
|
return documents.compose(request, documents.registry);
|
|
34
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
|
+
}
|
|
35
44
|
return renderEmail(request, registry);
|
|
36
45
|
}
|
|
37
46
|
export async function serve(registry, documents = null) {
|
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",
|
|
@@ -64,6 +64,8 @@
|
|
|
64
64
|
"@react-email/editor": "^1.5",
|
|
65
65
|
"@react-email/render": "^2.0.0",
|
|
66
66
|
"@tiptap/core": "^3",
|
|
67
|
+
"@tiptap/html": "^3",
|
|
68
|
+
"happy-dom": "^20.8.9",
|
|
67
69
|
"react": "^18.0 || ^19.0",
|
|
68
70
|
"vite": "^7.0.0 || ^8.0.0"
|
|
69
71
|
},
|
|
@@ -73,15 +75,24 @@
|
|
|
73
75
|
},
|
|
74
76
|
"@tiptap/core": {
|
|
75
77
|
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"@tiptap/html": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"happy-dom": {
|
|
83
|
+
"optional": true
|
|
76
84
|
}
|
|
77
85
|
},
|
|
78
86
|
"devDependencies": {
|
|
79
87
|
"@react-email/editor": "^1.5.3",
|
|
80
88
|
"@react-email/render": "^2.0.8",
|
|
81
89
|
"@tiptap/core": "^3",
|
|
90
|
+
"@tiptap/html": "^3",
|
|
91
|
+
"@tiptap/pm": "^3",
|
|
82
92
|
"@types/node": "^25.9.1",
|
|
83
93
|
"@types/react": "^19.2.15",
|
|
84
94
|
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
|
95
|
+
"happy-dom": "^20.8.9",
|
|
85
96
|
"oxfmt": "^0.52.0",
|
|
86
97
|
"oxlint": "^1.67.0",
|
|
87
98
|
"oxlint-tsgolint": "^0.23.0",
|
package/src/document.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { EmailTheming } from "@react-email/editor/plugins"
|
|
|
4
4
|
import { getSchema, resolveExtensions, type Extensions } from "@tiptap/core"
|
|
5
5
|
import type { Editor } from "@tiptap/core"
|
|
6
6
|
|
|
7
|
-
import type { DroppedNode, RenderResult } from "./runtime.js"
|
|
7
|
+
import type { DroppedNode, ParseResult, RenderResult } from "./runtime.js"
|
|
8
8
|
|
|
9
9
|
export type { DroppedNode }
|
|
10
10
|
|
|
@@ -61,6 +61,47 @@ export type RenderDocumentRequest = {
|
|
|
61
61
|
preview?: string | null
|
|
62
62
|
}
|
|
63
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
|
+
|
|
64
105
|
export async function composeDocument(
|
|
65
106
|
request: RenderDocumentRequest,
|
|
66
107
|
registry: DocumentRegistry,
|
|
@@ -76,15 +117,7 @@ export async function composeDocument(
|
|
|
76
117
|
)
|
|
77
118
|
}
|
|
78
119
|
|
|
79
|
-
const
|
|
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
|
-
}
|
|
120
|
+
const renderer = await resolveRenderer(request.type, registry)
|
|
88
121
|
|
|
89
122
|
const document =
|
|
90
123
|
renderer.transformDocument?.(request.document, request.context) ?? request.document
|
|
@@ -108,3 +141,24 @@ export async function composeDocument(
|
|
|
108
141
|
const { html, text } = await composeReactEmail(params)
|
|
109
142
|
return warnings.length > 0 ? { html, text, warnings } : { html, text }
|
|
110
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 =
|
|
@@ -64,6 +66,17 @@ const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
|
|
|
64
66
|
const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
|
|
65
67
|
const OUT_DIR = "tmp/react-email-rails"
|
|
66
68
|
const BUNDLE_FILE = "emails.js"
|
|
69
|
+
const require = createRequire(import.meta.url)
|
|
70
|
+
|
|
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
|
+
}
|
|
67
80
|
|
|
68
81
|
function normalizeSource(
|
|
69
82
|
option: EmailsOption | undefined,
|
|
@@ -101,6 +114,17 @@ function normalizeSource(
|
|
|
101
114
|
return { path, extensions, ignore, root, globArg }
|
|
102
115
|
}
|
|
103
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
|
+
|
|
104
128
|
export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
105
129
|
const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS)
|
|
106
130
|
const documentSource =
|
|
@@ -129,10 +153,17 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
129
153
|
handler(id) {
|
|
130
154
|
if (id === RESOLVED_SERVER) {
|
|
131
155
|
const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`]
|
|
156
|
+
const parserPeersAvailable =
|
|
157
|
+
documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"])
|
|
132
158
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
|
136
167
|
|
|
137
168
|
lines.push(
|
|
138
169
|
`const modules = import.meta.glob(${emailSource.globArg})`,
|
|
@@ -153,7 +184,7 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
153
184
|
` const extension = documentExtensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
|
|
154
185
|
` documentRegistry[toComponentName(path, ${JSON.stringify(documentSource.root)}, extension)] = documentModules[path]`,
|
|
155
186
|
`}`,
|
|
156
|
-
`export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument })`,
|
|
187
|
+
`export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? "createParseDocument(generateJSON)" : "parseDocument"} })`,
|
|
157
188
|
)
|
|
158
189
|
} else {
|
|
159
190
|
lines.push(`export const run = () => serve(registry)`)
|
|
@@ -179,7 +210,9 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
179
210
|
return {
|
|
180
211
|
environments: {
|
|
181
212
|
[EMAIL_ENVIRONMENT]: {
|
|
182
|
-
...(standalone && env.command === "build"
|
|
213
|
+
...(standalone && env.command === "build"
|
|
214
|
+
? { resolve: { noExternal: true }, define: WS_NATIVE_ADDON_OPT_OUT }
|
|
215
|
+
: {}),
|
|
183
216
|
build: {
|
|
184
217
|
ssr: true,
|
|
185
218
|
outDir: OUT_DIR,
|
package/src/runtime.ts
CHANGED
|
@@ -33,6 +33,13 @@ export type RenderDocumentRequest = {
|
|
|
33
33
|
preview?: string | null
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export type ParseDocumentRequest = {
|
|
37
|
+
kind: "parse"
|
|
38
|
+
type: string
|
|
39
|
+
html: string
|
|
40
|
+
context?: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
// A document node type that rendered to nothing, with how many times it occurred.
|
|
37
44
|
export type DroppedNode = { type: string; count: number }
|
|
38
45
|
|
|
@@ -40,11 +47,14 @@ export type DroppedNode = { type: string; count: number }
|
|
|
40
47
|
// extension rendered them). Component renders never carry warnings.
|
|
41
48
|
export type RenderResult = RenderedEmail & { warnings?: DroppedNode[] }
|
|
42
49
|
|
|
50
|
+
export type ParseResult = { document: unknown }
|
|
51
|
+
|
|
43
52
|
// Injected by the generated server module when documents are enabled, so `serve`
|
|
44
53
|
// renders documents without importing the editor module or its peer types.
|
|
45
54
|
export type DocumentSupport<Registry = unknown> = {
|
|
46
55
|
registry: Registry
|
|
47
56
|
compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>
|
|
57
|
+
parse: (request: ParseDocumentRequest, registry: Registry) => Promise<ParseResult>
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
type ProtocolMetadata = {
|
|
@@ -87,21 +97,33 @@ function isDocumentRequest(request: unknown): request is RenderDocumentRequest {
|
|
|
87
97
|
)
|
|
88
98
|
}
|
|
89
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
|
+
|
|
90
108
|
function isHealthRequest(request: unknown): request is HealthRequest {
|
|
91
109
|
return request !== null && typeof request === "object" && "health" in request
|
|
92
110
|
}
|
|
93
111
|
|
|
94
|
-
// Requests without a document kind are component renders, preserving the email path.
|
|
95
112
|
async function renderRequest<Registry>(
|
|
96
|
-
request: RenderRequest | RenderDocumentRequest,
|
|
113
|
+
request: RenderRequest | RenderDocumentRequest | ParseDocumentRequest,
|
|
97
114
|
registry: EmailRegistry,
|
|
98
115
|
documents: DocumentSupport<Registry> | null,
|
|
99
|
-
): Promise<RenderResult> {
|
|
116
|
+
): Promise<RenderResult | ParseResult> {
|
|
100
117
|
if (isDocumentRequest(request)) {
|
|
101
118
|
if (!documents) throw new Error("React email document rendering is not enabled")
|
|
102
119
|
return documents.compose(request, documents.registry)
|
|
103
120
|
}
|
|
104
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
|
+
|
|
105
127
|
return renderEmail(request, registry)
|
|
106
128
|
}
|
|
107
129
|
|
|
@@ -121,7 +143,10 @@ export async function serve<Registry = unknown>(
|
|
|
121
143
|
|
|
122
144
|
const write = isolateStdout()
|
|
123
145
|
try {
|
|
124
|
-
const request = JSON.parse(await readStdin()) as
|
|
146
|
+
const request = JSON.parse(await readStdin()) as
|
|
147
|
+
| RenderRequest
|
|
148
|
+
| RenderDocumentRequest
|
|
149
|
+
| ParseDocumentRequest
|
|
125
150
|
write(
|
|
126
151
|
JSON.stringify({
|
|
127
152
|
...(await renderRequest(request, registry, documents)),
|
|
@@ -186,7 +211,11 @@ async function writePersistentResponse<Registry>(
|
|
|
186
211
|
write: (chunk: string) => boolean,
|
|
187
212
|
): Promise<void> {
|
|
188
213
|
try {
|
|
189
|
-
const request = JSON.parse(line) as
|
|
214
|
+
const request = JSON.parse(line) as
|
|
215
|
+
| RenderRequest
|
|
216
|
+
| RenderDocumentRequest
|
|
217
|
+
| ParseDocumentRequest
|
|
218
|
+
| HealthRequest
|
|
190
219
|
if (isHealthRequest(request)) {
|
|
191
220
|
write(`${JSON.stringify(okResponse())}\n`)
|
|
192
221
|
return
|
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
|