react-email-rails 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/build.mjs +9 -11
- package/bin/dev.mjs +10 -13
- package/bin/shared.mjs +14 -0
- package/dist/document.d.ts +9 -8
- package/dist/document.js +66 -10
- package/dist/index.js +44 -14
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +25 -6
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +17 -1
- package/src/document.ts +114 -20
- package/src/index.ts +50 -29
- package/src/runtime.ts +61 -16
- package/src/version.ts +2 -2
package/bin/build.mjs
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createBuilder } from "vite"
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
exitIfHealthCheck,
|
|
5
|
+
fail,
|
|
6
|
+
isolatedViteConfig,
|
|
7
|
+
loadReactEmailRailsConfig,
|
|
8
|
+
} from "./shared.mjs"
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
process.stdout.write(
|
|
8
|
-
JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
|
|
9
|
-
)
|
|
10
|
-
process.exit(0)
|
|
11
|
-
}
|
|
10
|
+
exitIfHealthCheck()
|
|
12
11
|
|
|
13
12
|
const args = process.argv.slice(2)
|
|
14
13
|
const readOption = (long, short) => {
|
|
@@ -52,9 +51,8 @@ const { userConfig, plugin, vite } = await loadReactEmailRailsConfig({
|
|
|
52
51
|
configLoader,
|
|
53
52
|
})
|
|
54
53
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// of the email environment so client/global plugin hooks cannot break SSR output.
|
|
54
|
+
// Same isolation as the dev renderer: keep component resolve/transforms but leave unrelated
|
|
55
|
+
// app plugins out of the email environment so their hooks can't break SSR output.
|
|
58
56
|
const builder = await createBuilder(
|
|
59
57
|
isolatedViteConfig(userConfig, vite, {
|
|
60
58
|
root: root ?? userConfig.root,
|
package/bin/dev.mjs
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createServer, isRunnableDevEnvironment } from "vite"
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
exitIfHealthCheck,
|
|
5
|
+
fail,
|
|
6
|
+
isolatedViteConfig,
|
|
7
|
+
loadReactEmailRailsConfig,
|
|
8
|
+
} from "./shared.mjs"
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
process.stdout.write(
|
|
8
|
-
JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
|
|
9
|
-
)
|
|
10
|
-
process.exit(0)
|
|
11
|
-
}
|
|
10
|
+
exitIfHealthCheck()
|
|
12
11
|
|
|
13
12
|
const toStderr = (message) => process.stderr.write(`${message}\n`)
|
|
14
13
|
const logger = {
|
|
@@ -39,9 +38,7 @@ const { userConfig, plugin, vite } = await loadReactEmailRailsConfig({
|
|
|
39
38
|
mode: "development",
|
|
40
39
|
})
|
|
41
40
|
|
|
42
|
-
// Forward
|
|
43
|
-
// host's dev-server plugins, which have global side effects), so dev rendering
|
|
44
|
-
// stays close to the production email bundle.
|
|
41
|
+
// Forward only component resolve/compile config, so dev rendering stays close to the build.
|
|
45
42
|
const server = await createServer(
|
|
46
43
|
isolatedViteConfig(userConfig, vite, {
|
|
47
44
|
configFile: false,
|
|
@@ -53,8 +50,7 @@ const server = await createServer(
|
|
|
53
50
|
}),
|
|
54
51
|
)
|
|
55
52
|
|
|
56
|
-
// Render through the same `email` environment the production build
|
|
57
|
-
// and build resolve and compile components identically.
|
|
53
|
+
// Render through the same `email` environment as the production build, so the two match.
|
|
58
54
|
const environment = server.environments.email
|
|
59
55
|
if (!isRunnableDevEnvironment(environment)) {
|
|
60
56
|
await server.close()
|
|
@@ -63,6 +59,7 @@ if (!isRunnableDevEnvironment(environment)) {
|
|
|
63
59
|
|
|
64
60
|
try {
|
|
65
61
|
const { run } = await environment.runner.import("virtual:react-email-rails/server")
|
|
62
|
+
// Restore before run(): serve() re-isolates stdout with its own protocol writer.
|
|
66
63
|
restoreStdout()
|
|
67
64
|
await run()
|
|
68
65
|
} finally {
|
package/bin/shared.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { loadConfigFromFile, mergeConfig } from "vite"
|
|
2
2
|
|
|
3
|
+
import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
|
|
4
|
+
|
|
5
|
+
// Wire contract: must match the Symbol.for(...) keys in src/index.ts.
|
|
3
6
|
const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
|
|
4
7
|
const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
|
|
8
|
+
// Must mirror ReactEmailRailsViteOptions in src/index.ts and the option list in README.md.
|
|
5
9
|
const EMAIL_VITE_CONFIG_KEYS = [
|
|
6
10
|
"assetsInclude",
|
|
7
11
|
"css",
|
|
@@ -13,6 +17,16 @@ const EMAIL_VITE_CONFIG_KEYS = [
|
|
|
13
17
|
"resolve",
|
|
14
18
|
]
|
|
15
19
|
|
|
20
|
+
// Emit the version/protocol handshake and exit on --health. Shared by the build and dev bins.
|
|
21
|
+
export function exitIfHealthCheck() {
|
|
22
|
+
if (!process.argv.includes("--health")) return
|
|
23
|
+
|
|
24
|
+
process.stdout.write(
|
|
25
|
+
JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
|
|
26
|
+
)
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export async function loadReactEmailRailsConfig({
|
|
17
31
|
command,
|
|
18
32
|
mode,
|
package/dist/document.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Extensions } from "@tiptap/core";
|
|
2
|
-
import type { DroppedNode, RenderResult } from "./runtime.js";
|
|
3
|
-
export type { DroppedNode };
|
|
2
|
+
import type { DroppedNode, ParseDocumentRequest, ParseResult, RenderDocumentRequest, RenderResult } from "./runtime.js";
|
|
3
|
+
export type { DroppedNode, ParseDocumentRequest, RenderDocumentRequest };
|
|
4
4
|
export type DocumentRenderer = {
|
|
5
5
|
buildExtensions: (context: unknown) => Extensions;
|
|
6
6
|
transformDocument?: (document: unknown, context: unknown) => unknown;
|
|
@@ -8,11 +8,12 @@ export type DocumentRenderer = {
|
|
|
8
8
|
};
|
|
9
9
|
export type DocumentLoader = DocumentRenderer | (() => Promise<DocumentRenderer>);
|
|
10
10
|
export type DocumentRegistry = Record<string, DocumentLoader>;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
preview?: string | null;
|
|
11
|
+
type GenerateJSON = (html: string, extensions: Extensions) => unknown;
|
|
12
|
+
type RenderMarkdown = (markdown: string) => string | Promise<string>;
|
|
13
|
+
type ParseDependencies = {
|
|
14
|
+
generateJSON?: GenerateJSON;
|
|
15
|
+
renderMarkdown?: RenderMarkdown;
|
|
17
16
|
};
|
|
18
17
|
export declare function composeDocument(request: RenderDocumentRequest, registry: DocumentRegistry): Promise<RenderResult>;
|
|
18
|
+
export declare function parseDocument(request: ParseDocumentRequest, registry: DocumentRegistry, dependencies?: ParseDependencies): Promise<ParseResult>;
|
|
19
|
+
export declare function createParseDocument(generateJSON: GenerateJSON, renderMarkdown?: RenderMarkdown): (request: ParseDocumentRequest, registry: DocumentRegistry) => Promise<ParseResult>;
|
package/dist/document.js
CHANGED
|
@@ -7,9 +7,8 @@ import { getSchema, resolveExtensions } from "@tiptap/core";
|
|
|
7
7
|
const STRUCTURAL_NODE_TYPES = new Set(resolveExtensions([StarterKit, EmailTheming])
|
|
8
8
|
.filter((extension) => extension.type === "node" && !(extension instanceof EmailNode))
|
|
9
9
|
.map((extension) => extension.name));
|
|
10
|
-
// composeReactEmail renders a node as null when no extension matches
|
|
11
|
-
//
|
|
12
|
-
// warnings report the silent case: an in-schema node with no email renderer.
|
|
10
|
+
// composeReactEmail renders a node as null when no extension matches or the match isn't an
|
|
11
|
+
// EmailNode; mirror that here so warnings catch the silent case (in-schema node, no email renderer).
|
|
13
12
|
function collectDroppedNodes(document, extensions) {
|
|
14
13
|
const byName = new Map();
|
|
15
14
|
for (const extension of extensions)
|
|
@@ -35,6 +34,54 @@ function collectDroppedNodes(document, extensions) {
|
|
|
35
34
|
walk(document.content);
|
|
36
35
|
return [...counts].map(([type, count]) => ({ type, count }));
|
|
37
36
|
}
|
|
37
|
+
async function resolveRenderer(type, registry) {
|
|
38
|
+
const loader = registry[type];
|
|
39
|
+
if (!loader)
|
|
40
|
+
throw new Error(`React email document renderer not found: ${type}`);
|
|
41
|
+
const renderer = typeof loader === "function" ? await loader() : loader;
|
|
42
|
+
if (typeof renderer.buildExtensions !== "function") {
|
|
43
|
+
throw new Error(`React email document renderer must export a buildExtensions function: ${type}`);
|
|
44
|
+
}
|
|
45
|
+
return renderer;
|
|
46
|
+
}
|
|
47
|
+
async function loadGenerateJSON() {
|
|
48
|
+
try {
|
|
49
|
+
const mod = (await import(/* @vite-ignore */ "@tiptap/html"));
|
|
50
|
+
if (typeof mod.generateJSON === "function")
|
|
51
|
+
return mod.generateJSON;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
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"})`);
|
|
55
|
+
}
|
|
56
|
+
throw new Error("@tiptap/html is missing the expected generateJSON export; check the installed version");
|
|
57
|
+
}
|
|
58
|
+
async function loadRenderMarkdown() {
|
|
59
|
+
try {
|
|
60
|
+
const mod = (await import(/* @vite-ignore */ "marked"));
|
|
61
|
+
const marked = mod.marked;
|
|
62
|
+
if (marked && typeof marked.parse === "function") {
|
|
63
|
+
const parse = marked.parse.bind(marked);
|
|
64
|
+
return (markdown) => parse(markdown);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new Error(`marked is required to parse Markdown documents; install it before calling parse with markdown (${error instanceof Error ? error.message : "module load failed"})`);
|
|
69
|
+
}
|
|
70
|
+
throw new Error("marked is missing the expected parse export; check the installed version");
|
|
71
|
+
}
|
|
72
|
+
// Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
|
|
73
|
+
async function resolveHtmlInput(request, dependencies) {
|
|
74
|
+
const hasHtml = request.html !== undefined;
|
|
75
|
+
const hasMarkdown = request.markdown !== undefined;
|
|
76
|
+
if (hasHtml === hasMarkdown) {
|
|
77
|
+
throw new Error("parse request must include exactly one of `html` or `markdown`");
|
|
78
|
+
}
|
|
79
|
+
if (hasMarkdown) {
|
|
80
|
+
const renderMarkdown = dependencies.renderMarkdown ?? (await loadRenderMarkdown());
|
|
81
|
+
return renderMarkdown(request.markdown);
|
|
82
|
+
}
|
|
83
|
+
return request.html;
|
|
84
|
+
}
|
|
38
85
|
export async function composeDocument(request, registry) {
|
|
39
86
|
// Fail legibly if the optional editor peers are present but their shape shifted.
|
|
40
87
|
if (typeof composeReactEmail !== "function" ||
|
|
@@ -42,13 +89,7 @@ export async function composeDocument(request, registry) {
|
|
|
42
89
|
typeof getSchema !== "function") {
|
|
43
90
|
throw new Error("@react-email/editor or @tiptap/core is missing expected exports (composeReactEmail/resolveExtensions/getSchema); check the installed versions");
|
|
44
91
|
}
|
|
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
|
-
}
|
|
92
|
+
const renderer = await resolveRenderer(request.type, registry);
|
|
52
93
|
const document = renderer.transformDocument?.(request.document, request.context) ?? request.document;
|
|
53
94
|
const extensions = resolveExtensions(renderer.buildExtensions(request.context));
|
|
54
95
|
const schema = getSchema(extensions);
|
|
@@ -67,3 +108,18 @@ export async function composeDocument(request, registry) {
|
|
|
67
108
|
const { html, text } = await composeReactEmail(params);
|
|
68
109
|
return warnings.length > 0 ? { html, text, warnings } : { html, text };
|
|
69
110
|
}
|
|
111
|
+
export async function parseDocument(request, registry, dependencies = {}) {
|
|
112
|
+
const renderer = await resolveRenderer(request.type, registry);
|
|
113
|
+
const extensions = resolveExtensions(renderer.buildExtensions(request.context));
|
|
114
|
+
const schema = getSchema(extensions);
|
|
115
|
+
const html = await resolveHtmlInput(request, dependencies);
|
|
116
|
+
const parseHTML = dependencies.generateJSON ?? (await loadGenerateJSON());
|
|
117
|
+
const parsed = parseHTML(html, extensions);
|
|
118
|
+
const document = schema.nodeFromJSON(parsed).toJSON();
|
|
119
|
+
return { document };
|
|
120
|
+
}
|
|
121
|
+
export function createParseDocument(generateJSON, renderMarkdown) {
|
|
122
|
+
// Omit renderMarkdown entirely when unset; exactOptionalPropertyTypes forbids passing `undefined`.
|
|
123
|
+
const dependencies = renderMarkdown === undefined ? { generateJSON } : { generateJSON, renderMarkdown };
|
|
124
|
+
return (request, registry) => parseDocument(request, registry, dependencies);
|
|
125
|
+
}
|
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"];
|
|
@@ -10,10 +11,20 @@ const RESOLVED_MAIN = `\0${VIRTUAL_MAIN}`;
|
|
|
10
11
|
const VIRTUAL_MODULE_PATTERN = /virtual:react-email-rails\/(?:server|main)$/;
|
|
11
12
|
// The dedicated build environment that emits the server-side email bundle.
|
|
12
13
|
export const EMAIL_ENVIRONMENT = "email";
|
|
14
|
+
// Wire contract: must match the Symbol.for(...) keys the bins read in bin/shared.mjs.
|
|
13
15
|
const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
|
|
14
16
|
const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite");
|
|
17
|
+
// Must match Ruby's Configuration::BUNDLE_PATH (check_version_sync.rb asserts it).
|
|
15
18
|
const OUT_DIR = "tmp/react-email-rails";
|
|
16
19
|
const BUNDLE_FILE = "emails.js";
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
// happy-dom (via @tiptap/html) pulls in `ws`, which guards optional native-addon requires behind
|
|
22
|
+
// these flags. Setting them lets a standalone (noExternal) build tree-shake the uninstalled
|
|
23
|
+
// bufferutil/utf-8-validate requires away; ws's pure-JS path 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
|
|
@@ -59,13 +81,23 @@ export function reactEmailRails(options = {}) {
|
|
|
59
81
|
filter: { id: VIRTUAL_MODULE_PATTERN },
|
|
60
82
|
handler(id) {
|
|
61
83
|
if (id === RESOLVED_SERVER) {
|
|
62
|
-
const lines = [`import {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
const lines = [`import { buildRegistry, serve } from "react-email-rails/runtime"`];
|
|
85
|
+
const parserPeersAvailable = documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"]);
|
|
86
|
+
// Markdown parsing layers `marked` on top of the HTML parser peers.
|
|
87
|
+
const markdownPeerAvailable = parserPeersAvailable && optionalPeersAvailable(["marked"]);
|
|
88
|
+
if (documentSource) {
|
|
89
|
+
lines.push(parserPeersAvailable
|
|
90
|
+
? `import { composeDocument, createParseDocument } from "react-email-rails/document"`
|
|
91
|
+
: `import { composeDocument, parseDocument } from "react-email-rails/document"`);
|
|
92
|
+
if (parserPeersAvailable) {
|
|
93
|
+
lines.push(`import { generateJSON } from "@tiptap/html"`);
|
|
94
|
+
if (markdownPeerAvailable)
|
|
95
|
+
lines.push(`import { marked } from "marked"`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
lines.push(`const registry = buildRegistry(import.meta.glob(${emailSource.globArg}), ${JSON.stringify(emailSource.extensions)}, ${JSON.stringify(emailSource.root)})`);
|
|
67
99
|
if (documentSource) {
|
|
68
|
-
lines.push(`const
|
|
100
|
+
lines.push(`const documentRegistry = buildRegistry(import.meta.glob(${documentSource.globArg}), ${JSON.stringify(documentSource.extensions)}, ${JSON.stringify(documentSource.root)})`, `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? `createParseDocument(generateJSON${markdownPeerAvailable ? ", (markdown) => marked.parse(markdown)" : ""})` : "parseDocument"} })`);
|
|
69
101
|
}
|
|
70
102
|
else {
|
|
71
103
|
lines.push(`export const run = () => serve(registry)`);
|
|
@@ -78,17 +110,15 @@ export function reactEmailRails(options = {}) {
|
|
|
78
110
|
},
|
|
79
111
|
},
|
|
80
112
|
config(_config, env) {
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// The environment is a server consumer. Production standalone builds inline
|
|
85
|
-
// Node dependencies by default so Rails runtime images do not need
|
|
86
|
-
// node_modules; dev rendering keeps dependencies external for Vite's module
|
|
87
|
-
// runner.
|
|
113
|
+
// Dedicated `email` build environment: the react-email-rails-build bin builds it with an
|
|
114
|
+
// isolated plugin stack so host plugins can't break email SSR. Standalone builds inline
|
|
115
|
+
// Node deps (so Rails images need no node_modules); dev keeps them external for the runner.
|
|
88
116
|
return {
|
|
89
117
|
environments: {
|
|
90
118
|
[EMAIL_ENVIRONMENT]: {
|
|
91
|
-
...(standalone && env.command === "build"
|
|
119
|
+
...(standalone && env.command === "build"
|
|
120
|
+
? { resolve: { noExternal: true }, define: WS_NATIVE_ADDON_OPT_OUT }
|
|
121
|
+
: {}),
|
|
92
122
|
build: {
|
|
93
123
|
ssr: true,
|
|
94
124
|
outDir: OUT_DIR,
|
package/dist/runtime.d.ts
CHANGED
|
@@ -24,6 +24,13 @@ 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
|
+
markdown?: string;
|
|
32
|
+
context?: unknown;
|
|
33
|
+
};
|
|
27
34
|
export type DroppedNode = {
|
|
28
35
|
type: string;
|
|
29
36
|
count: number;
|
|
@@ -31,14 +38,19 @@ export type DroppedNode = {
|
|
|
31
38
|
export type RenderResult = RenderedEmail & {
|
|
32
39
|
warnings?: DroppedNode[];
|
|
33
40
|
};
|
|
41
|
+
export type ParseResult = {
|
|
42
|
+
document: unknown;
|
|
43
|
+
};
|
|
34
44
|
export type DocumentSupport<Registry = unknown> = {
|
|
35
45
|
registry: Registry;
|
|
36
46
|
compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>;
|
|
47
|
+
parse: (request: ParseDocumentRequest, registry: Registry) => Promise<ParseResult>;
|
|
37
48
|
};
|
|
38
49
|
export type EmailRenderOptions = {
|
|
39
50
|
html?: ReactEmailRenderOptions;
|
|
40
51
|
text?: ReactEmailRenderOptions;
|
|
41
52
|
};
|
|
42
53
|
export declare function toComponentName(globPath: string, root: string, extension: string): string;
|
|
54
|
+
export declare function buildRegistry(modules: EmailRegistry, extensions: string[], root: string): EmailRegistry;
|
|
43
55
|
export declare function renderEmail(request: RenderRequest, registry: EmailRegistry): Promise<RenderedEmail>;
|
|
44
56
|
export declare function serve<Registry = unknown>(registry: EmailRegistry, documents?: DocumentSupport<Registry> | null): Promise<void>;
|
package/dist/runtime.js
CHANGED
|
@@ -5,6 +5,15 @@ export function toComponentName(globPath, root, extension) {
|
|
|
5
5
|
const start = globPath.lastIndexOf(root) + root.length;
|
|
6
6
|
return globPath.slice(start, globPath.length - extension.length);
|
|
7
7
|
}
|
|
8
|
+
// Map glob results to a component-name registry (used for both email and document registries).
|
|
9
|
+
export function buildRegistry(modules, extensions, root) {
|
|
10
|
+
const registry = Object.create(null);
|
|
11
|
+
for (const [path, loader] of Object.entries(modules)) {
|
|
12
|
+
const extension = extensions.find((ext) => path.endsWith(ext)) ?? path.slice(path.lastIndexOf("."));
|
|
13
|
+
registry[toComponentName(path, root, extension)] = loader;
|
|
14
|
+
}
|
|
15
|
+
return registry;
|
|
16
|
+
}
|
|
8
17
|
export async function renderEmail(request, registry) {
|
|
9
18
|
const loader = registry[request.component];
|
|
10
19
|
if (!loader)
|
|
@@ -20,18 +29,26 @@ export async function renderEmail(request, registry) {
|
|
|
20
29
|
function isDocumentRequest(request) {
|
|
21
30
|
return (request !== null &&
|
|
22
31
|
typeof request === "object" &&
|
|
32
|
+
"kind" in request &&
|
|
23
33
|
request.kind === "document");
|
|
24
34
|
}
|
|
35
|
+
function isParseRequest(request) {
|
|
36
|
+
return (request !== null && typeof request === "object" && "kind" in request && request.kind === "parse");
|
|
37
|
+
}
|
|
25
38
|
function isHealthRequest(request) {
|
|
26
39
|
return request !== null && typeof request === "object" && "health" in request;
|
|
27
40
|
}
|
|
28
|
-
// Requests without a document kind are component renders, preserving the email path.
|
|
29
41
|
async function renderRequest(request, registry, documents) {
|
|
30
42
|
if (isDocumentRequest(request)) {
|
|
31
43
|
if (!documents)
|
|
32
44
|
throw new Error("React email document rendering is not enabled");
|
|
33
45
|
return documents.compose(request, documents.registry);
|
|
34
46
|
}
|
|
47
|
+
if (isParseRequest(request)) {
|
|
48
|
+
if (!documents)
|
|
49
|
+
throw new Error("React email document rendering is not enabled");
|
|
50
|
+
return documents.parse(request, documents.registry);
|
|
51
|
+
}
|
|
35
52
|
return renderEmail(request, registry);
|
|
36
53
|
}
|
|
37
54
|
export async function serve(registry, documents = null) {
|
|
@@ -56,13 +73,15 @@ export async function serve(registry, documents = null) {
|
|
|
56
73
|
process.exitCode = 1;
|
|
57
74
|
}
|
|
58
75
|
}
|
|
59
|
-
// Reserve stdout for the JSON render protocol
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// a response frame. Returns the writer to use for protocol output.
|
|
76
|
+
// Reserve stdout for the JSON render protocol: stray writes (e.g. console.log, which Node
|
|
77
|
+
// routes through process.stdout.write) are diverted to stderr so they can't corrupt a frame.
|
|
78
|
+
// Returns the writer to use for protocol output.
|
|
63
79
|
function isolateStdout() {
|
|
64
80
|
const protocolWrite = process.stdout.write.bind(process.stdout);
|
|
65
|
-
|
|
81
|
+
// Forward encoding/callback (and the function-as-second-arg overload), not just chunk.
|
|
82
|
+
process.stdout.write = ((chunk, encoding, callback) => typeof encoding === "function"
|
|
83
|
+
? process.stderr.write(chunk, encoding)
|
|
84
|
+
: process.stderr.write(chunk, encoding, callback));
|
|
66
85
|
return (chunk) => protocolWrite(chunk);
|
|
67
86
|
}
|
|
68
87
|
function readStdin() {
|
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.4.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.4.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.4.0",
|
|
4
4
|
"description": "Build and send emails using React and Rails",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -64,6 +64,9 @@
|
|
|
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",
|
|
69
|
+
"marked": "^18",
|
|
67
70
|
"react": "^18.0 || ^19.0",
|
|
68
71
|
"vite": "^7.0.0 || ^8.0.0"
|
|
69
72
|
},
|
|
@@ -73,15 +76,28 @@
|
|
|
73
76
|
},
|
|
74
77
|
"@tiptap/core": {
|
|
75
78
|
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"@tiptap/html": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"happy-dom": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"marked": {
|
|
87
|
+
"optional": true
|
|
76
88
|
}
|
|
77
89
|
},
|
|
78
90
|
"devDependencies": {
|
|
79
91
|
"@react-email/editor": "^1.5.3",
|
|
80
92
|
"@react-email/render": "^2.0.8",
|
|
81
93
|
"@tiptap/core": "^3",
|
|
94
|
+
"@tiptap/html": "^3",
|
|
95
|
+
"@tiptap/pm": "^3",
|
|
82
96
|
"@types/node": "^25.9.1",
|
|
83
97
|
"@types/react": "^19.2.15",
|
|
84
98
|
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
|
99
|
+
"happy-dom": "^20.8.9",
|
|
100
|
+
"marked": "^18.0.5",
|
|
85
101
|
"oxfmt": "^0.52.0",
|
|
86
102
|
"oxlint": "^1.67.0",
|
|
87
103
|
"oxlint-tsgolint": "^0.23.0",
|
package/src/document.ts
CHANGED
|
@@ -4,9 +4,16 @@ 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 {
|
|
7
|
+
import type {
|
|
8
|
+
DroppedNode,
|
|
9
|
+
ParseDocumentRequest,
|
|
10
|
+
ParseResult,
|
|
11
|
+
RenderDocumentRequest,
|
|
12
|
+
RenderResult,
|
|
13
|
+
} from "./runtime.js"
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
// Re-exported from runtime.ts (the single source) to keep react-email-rails/document's surface.
|
|
16
|
+
export type { DroppedNode, ParseDocumentRequest, RenderDocumentRequest }
|
|
10
17
|
|
|
11
18
|
export type DocumentRenderer = {
|
|
12
19
|
buildExtensions: (context: unknown) => Extensions
|
|
@@ -22,9 +29,8 @@ const STRUCTURAL_NODE_TYPES: ReadonlySet<string> = new Set(
|
|
|
22
29
|
.map((extension) => extension.name),
|
|
23
30
|
)
|
|
24
31
|
|
|
25
|
-
// composeReactEmail renders a node as null when no extension matches
|
|
26
|
-
//
|
|
27
|
-
// warnings report the silent case: an in-schema node with no email renderer.
|
|
32
|
+
// composeReactEmail renders a node as null when no extension matches or the match isn't an
|
|
33
|
+
// EmailNode; mirror that here so warnings catch the silent case (in-schema node, no email renderer).
|
|
28
34
|
function collectDroppedNodes(document: unknown, extensions: Extensions): DroppedNode[] {
|
|
29
35
|
const byName = new Map<string, Extensions[number]>()
|
|
30
36
|
for (const extension of extensions) byName.set(extension.name, extension)
|
|
@@ -53,12 +59,83 @@ function collectDroppedNodes(document: unknown, extensions: Extensions): Dropped
|
|
|
53
59
|
export type DocumentLoader = DocumentRenderer | (() => Promise<DocumentRenderer>)
|
|
54
60
|
export type DocumentRegistry = Record<string, DocumentLoader>
|
|
55
61
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
type GenerateJSON = (html: string, extensions: Extensions) => unknown
|
|
63
|
+
type RenderMarkdown = (markdown: string) => string | Promise<string>
|
|
64
|
+
|
|
65
|
+
// Bound at build time by createParseDocument when the peers are bundled; lazy-loaded otherwise.
|
|
66
|
+
type ParseDependencies = {
|
|
67
|
+
generateJSON?: GenerateJSON
|
|
68
|
+
renderMarkdown?: RenderMarkdown
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolveRenderer(
|
|
72
|
+
type: string,
|
|
73
|
+
registry: DocumentRegistry,
|
|
74
|
+
): Promise<DocumentRenderer> {
|
|
75
|
+
const loader = registry[type]
|
|
76
|
+
if (!loader) throw new Error(`React email document renderer not found: ${type}`)
|
|
77
|
+
|
|
78
|
+
const renderer = typeof loader === "function" ? await loader() : loader
|
|
79
|
+
if (typeof renderer.buildExtensions !== "function") {
|
|
80
|
+
throw new Error(`React email document renderer must export a buildExtensions function: ${type}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return renderer
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadGenerateJSON(): Promise<GenerateJSON> {
|
|
87
|
+
try {
|
|
88
|
+
const mod = (await import(/* @vite-ignore */ "@tiptap/html")) as {
|
|
89
|
+
generateJSON?: unknown
|
|
90
|
+
}
|
|
91
|
+
if (typeof mod.generateJSON === "function") return mod.generateJSON as GenerateJSON
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`@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"})`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw new Error(
|
|
99
|
+
"@tiptap/html is missing the expected generateJSON export; check the installed version",
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function loadRenderMarkdown(): Promise<RenderMarkdown> {
|
|
104
|
+
try {
|
|
105
|
+
const mod = (await import(/* @vite-ignore */ "marked")) as {
|
|
106
|
+
marked?: { parse?: (markdown: string) => string | Promise<string> }
|
|
107
|
+
}
|
|
108
|
+
const marked = mod.marked
|
|
109
|
+
if (marked && typeof marked.parse === "function") {
|
|
110
|
+
const parse = marked.parse.bind(marked)
|
|
111
|
+
return (markdown) => parse(markdown)
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`marked is required to parse Markdown documents; install it before calling parse with markdown (${error instanceof Error ? error.message : "module load failed"})`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw new Error("marked is missing the expected parse export; check the installed version")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
|
|
123
|
+
async function resolveHtmlInput(
|
|
124
|
+
request: ParseDocumentRequest,
|
|
125
|
+
dependencies: ParseDependencies,
|
|
126
|
+
): Promise<string> {
|
|
127
|
+
const hasHtml = request.html !== undefined
|
|
128
|
+
const hasMarkdown = request.markdown !== undefined
|
|
129
|
+
if (hasHtml === hasMarkdown) {
|
|
130
|
+
throw new Error("parse request must include exactly one of `html` or `markdown`")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (hasMarkdown) {
|
|
134
|
+
const renderMarkdown = dependencies.renderMarkdown ?? (await loadRenderMarkdown())
|
|
135
|
+
return renderMarkdown(request.markdown as string)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return request.html as string
|
|
62
139
|
}
|
|
63
140
|
|
|
64
141
|
export async function composeDocument(
|
|
@@ -76,15 +153,7 @@ export async function composeDocument(
|
|
|
76
153
|
)
|
|
77
154
|
}
|
|
78
155
|
|
|
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
|
-
}
|
|
156
|
+
const renderer = await resolveRenderer(request.type, registry)
|
|
88
157
|
|
|
89
158
|
const document =
|
|
90
159
|
renderer.transformDocument?.(request.document, request.context) ?? request.document
|
|
@@ -108,3 +177,28 @@ export async function composeDocument(
|
|
|
108
177
|
const { html, text } = await composeReactEmail(params)
|
|
109
178
|
return warnings.length > 0 ? { html, text, warnings } : { html, text }
|
|
110
179
|
}
|
|
180
|
+
|
|
181
|
+
export async function parseDocument(
|
|
182
|
+
request: ParseDocumentRequest,
|
|
183
|
+
registry: DocumentRegistry,
|
|
184
|
+
dependencies: ParseDependencies = {},
|
|
185
|
+
): Promise<ParseResult> {
|
|
186
|
+
const renderer = await resolveRenderer(request.type, registry)
|
|
187
|
+
const extensions = resolveExtensions(renderer.buildExtensions(request.context))
|
|
188
|
+
const schema = getSchema(extensions)
|
|
189
|
+
|
|
190
|
+
const html = await resolveHtmlInput(request, dependencies)
|
|
191
|
+
const parseHTML = dependencies.generateJSON ?? (await loadGenerateJSON())
|
|
192
|
+
const parsed = parseHTML(html, extensions)
|
|
193
|
+
const document = schema.nodeFromJSON(parsed).toJSON()
|
|
194
|
+
|
|
195
|
+
return { document }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function createParseDocument(generateJSON: GenerateJSON, renderMarkdown?: RenderMarkdown) {
|
|
199
|
+
// Omit renderMarkdown entirely when unset; exactOptionalPropertyTypes forbids passing `undefined`.
|
|
200
|
+
const dependencies: ParseDependencies =
|
|
201
|
+
renderMarkdown === undefined ? { generateJSON } : { generateJSON, renderMarkdown }
|
|
202
|
+
return (request: ParseDocumentRequest, registry: DocumentRegistry): Promise<ParseResult> =>
|
|
203
|
+
parseDocument(request, registry, dependencies)
|
|
204
|
+
}
|
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,8 +12,7 @@ export type EmailsOption =
|
|
|
10
12
|
|
|
11
13
|
export type ReactEmailRailsOptions = {
|
|
12
14
|
emails?: EmailsOption
|
|
13
|
-
// Editor document renderers, discovered like emails. Off by default
|
|
14
|
-
// to enable with defaults, or a path/options object to customize discovery.
|
|
15
|
+
// Editor document renderers, discovered like emails. Off by default.
|
|
15
16
|
documents?: EmailsOption | boolean
|
|
16
17
|
standalone?: boolean
|
|
17
18
|
vite?: ReactEmailRailsViteOptions
|
|
@@ -60,10 +61,21 @@ const VIRTUAL_MODULE_PATTERN = /virtual:react-email-rails\/(?:server|main)$/
|
|
|
60
61
|
|
|
61
62
|
// The dedicated build environment that emits the server-side email bundle.
|
|
62
63
|
export const EMAIL_ENVIRONMENT = "email"
|
|
64
|
+
// Wire contract: must match the Symbol.for(...) keys the bins read in bin/shared.mjs.
|
|
63
65
|
const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
|
|
64
66
|
const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
|
|
67
|
+
// Must match Ruby's Configuration::BUNDLE_PATH (check_version_sync.rb asserts it).
|
|
65
68
|
const OUT_DIR = "tmp/react-email-rails"
|
|
66
69
|
const BUNDLE_FILE = "emails.js"
|
|
70
|
+
const require = createRequire(import.meta.url)
|
|
71
|
+
|
|
72
|
+
// happy-dom (via @tiptap/html) pulls in `ws`, which guards optional native-addon requires behind
|
|
73
|
+
// these flags. Setting them lets a standalone (noExternal) build tree-shake the uninstalled
|
|
74
|
+
// bufferutil/utf-8-validate requires away; ws's pure-JS path is all the HTML parser needs.
|
|
75
|
+
const WS_NATIVE_ADDON_OPT_OUT = {
|
|
76
|
+
"process.env.WS_NO_BUFFER_UTIL": "'1'",
|
|
77
|
+
"process.env.WS_NO_UTF_8_VALIDATE": "'1'",
|
|
78
|
+
}
|
|
67
79
|
|
|
68
80
|
function normalizeSource(
|
|
69
81
|
option: EmailsOption | undefined,
|
|
@@ -101,6 +113,17 @@ function normalizeSource(
|
|
|
101
113
|
return { path, extensions, ignore, root, globArg }
|
|
102
114
|
}
|
|
103
115
|
|
|
116
|
+
function optionalPeersAvailable(specifiers: string[]): boolean {
|
|
117
|
+
return specifiers.every((specifier) => {
|
|
118
|
+
try {
|
|
119
|
+
require.resolve(specifier)
|
|
120
|
+
return true
|
|
121
|
+
} catch {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
104
127
|
export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
105
128
|
const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS)
|
|
106
129
|
const documentSource =
|
|
@@ -128,32 +151,32 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
128
151
|
filter: { id: VIRTUAL_MODULE_PATTERN },
|
|
129
152
|
handler(id) {
|
|
130
153
|
if (id === RESOLVED_SERVER) {
|
|
131
|
-
const lines = [`import {
|
|
154
|
+
const lines = [`import { buildRegistry, serve } from "react-email-rails/runtime"`]
|
|
155
|
+
const parserPeersAvailable =
|
|
156
|
+
documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"])
|
|
157
|
+
// Markdown parsing layers `marked` on top of the HTML parser peers.
|
|
158
|
+
const markdownPeerAvailable = parserPeersAvailable && optionalPeersAvailable(["marked"])
|
|
132
159
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
160
|
+
if (documentSource) {
|
|
161
|
+
lines.push(
|
|
162
|
+
parserPeersAvailable
|
|
163
|
+
? `import { composeDocument, createParseDocument } from "react-email-rails/document"`
|
|
164
|
+
: `import { composeDocument, parseDocument } from "react-email-rails/document"`,
|
|
165
|
+
)
|
|
166
|
+
if (parserPeersAvailable) {
|
|
167
|
+
lines.push(`import { generateJSON } from "@tiptap/html"`)
|
|
168
|
+
if (markdownPeerAvailable) lines.push(`import { marked } from "marked"`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
136
171
|
|
|
137
172
|
lines.push(
|
|
138
|
-
`const
|
|
139
|
-
`const extensions = ${JSON.stringify(emailSource.extensions)}`,
|
|
140
|
-
`const registry = Object.create(null)`,
|
|
141
|
-
`for (const path in modules) {`,
|
|
142
|
-
` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
|
|
143
|
-
` registry[toComponentName(path, ${JSON.stringify(emailSource.root)}, extension)] = modules[path]`,
|
|
144
|
-
`}`,
|
|
173
|
+
`const registry = buildRegistry(import.meta.glob(${emailSource.globArg}), ${JSON.stringify(emailSource.extensions)}, ${JSON.stringify(emailSource.root)})`,
|
|
145
174
|
)
|
|
146
175
|
|
|
147
176
|
if (documentSource) {
|
|
148
177
|
lines.push(
|
|
149
|
-
`const
|
|
150
|
-
`const
|
|
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 })`,
|
|
178
|
+
`const documentRegistry = buildRegistry(import.meta.glob(${documentSource.globArg}), ${JSON.stringify(documentSource.extensions)}, ${JSON.stringify(documentSource.root)})`,
|
|
179
|
+
`export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? `createParseDocument(generateJSON${markdownPeerAvailable ? ", (markdown) => marked.parse(markdown)" : ""})` : "parseDocument"} })`,
|
|
157
180
|
)
|
|
158
181
|
} else {
|
|
159
182
|
lines.push(`export const run = () => serve(registry)`)
|
|
@@ -169,17 +192,15 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
169
192
|
},
|
|
170
193
|
|
|
171
194
|
config(_config, env: ConfigEnv) {
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// The environment is a server consumer. Production standalone builds inline
|
|
176
|
-
// Node dependencies by default so Rails runtime images do not need
|
|
177
|
-
// node_modules; dev rendering keeps dependencies external for Vite's module
|
|
178
|
-
// runner.
|
|
195
|
+
// Dedicated `email` build environment: the react-email-rails-build bin builds it with an
|
|
196
|
+
// isolated plugin stack so host plugins can't break email SSR. Standalone builds inline
|
|
197
|
+
// Node deps (so Rails images need no node_modules); dev keeps them external for the runner.
|
|
179
198
|
return {
|
|
180
199
|
environments: {
|
|
181
200
|
[EMAIL_ENVIRONMENT]: {
|
|
182
|
-
...(standalone && env.command === "build"
|
|
201
|
+
...(standalone && env.command === "build"
|
|
202
|
+
? { resolve: { noExternal: true }, define: WS_NATIVE_ADDON_OPT_OUT }
|
|
203
|
+
: {}),
|
|
183
204
|
build: {
|
|
184
205
|
ssr: true,
|
|
185
206
|
outDir: OUT_DIR,
|
package/src/runtime.ts
CHANGED
|
@@ -33,18 +33,28 @@ export type RenderDocumentRequest = {
|
|
|
33
33
|
preview?: string | null
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Exactly one of `html` or `markdown` is set.
|
|
37
|
+
export type ParseDocumentRequest = {
|
|
38
|
+
kind: "parse"
|
|
39
|
+
type: string
|
|
40
|
+
html?: string
|
|
41
|
+
markdown?: string
|
|
42
|
+
context?: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
// A document node type that rendered to nothing, with how many times it occurred.
|
|
37
46
|
export type DroppedNode = { type: string; count: number }
|
|
38
47
|
|
|
39
|
-
// A render result plus
|
|
40
|
-
// extension rendered them). Component renders never carry warnings.
|
|
48
|
+
// A render result plus non-fatal warnings (dropped document nodes); component renders carry none.
|
|
41
49
|
export type RenderResult = RenderedEmail & { warnings?: DroppedNode[] }
|
|
42
50
|
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
export type ParseResult = { document: unknown }
|
|
52
|
+
|
|
53
|
+
// Injected by the generated server module so `serve` renders documents without importing the editor module.
|
|
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 = {
|
|
@@ -62,6 +72,21 @@ export function toComponentName(globPath: string, root: string, extension: strin
|
|
|
62
72
|
return globPath.slice(start, globPath.length - extension.length)
|
|
63
73
|
}
|
|
64
74
|
|
|
75
|
+
// Map glob results to a component-name registry (used for both email and document registries).
|
|
76
|
+
export function buildRegistry(
|
|
77
|
+
modules: EmailRegistry,
|
|
78
|
+
extensions: string[],
|
|
79
|
+
root: string,
|
|
80
|
+
): EmailRegistry {
|
|
81
|
+
const registry: EmailRegistry = Object.create(null)
|
|
82
|
+
for (const [path, loader] of Object.entries(modules)) {
|
|
83
|
+
const extension =
|
|
84
|
+
extensions.find((ext) => path.endsWith(ext)) ?? path.slice(path.lastIndexOf("."))
|
|
85
|
+
registry[toComponentName(path, root, extension)] = loader
|
|
86
|
+
}
|
|
87
|
+
return registry
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
export async function renderEmail(
|
|
66
91
|
request: RenderRequest,
|
|
67
92
|
registry: EmailRegistry,
|
|
@@ -83,7 +108,14 @@ function isDocumentRequest(request: unknown): request is RenderDocumentRequest {
|
|
|
83
108
|
return (
|
|
84
109
|
request !== null &&
|
|
85
110
|
typeof request === "object" &&
|
|
86
|
-
|
|
111
|
+
"kind" in request &&
|
|
112
|
+
request.kind === "document"
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isParseRequest(request: unknown): request is ParseDocumentRequest {
|
|
117
|
+
return (
|
|
118
|
+
request !== null && typeof request === "object" && "kind" in request && request.kind === "parse"
|
|
87
119
|
)
|
|
88
120
|
}
|
|
89
121
|
|
|
@@ -91,17 +123,21 @@ function isHealthRequest(request: unknown): request is HealthRequest {
|
|
|
91
123
|
return request !== null && typeof request === "object" && "health" in request
|
|
92
124
|
}
|
|
93
125
|
|
|
94
|
-
// Requests without a document kind are component renders, preserving the email path.
|
|
95
126
|
async function renderRequest<Registry>(
|
|
96
|
-
request: RenderRequest | RenderDocumentRequest,
|
|
127
|
+
request: RenderRequest | RenderDocumentRequest | ParseDocumentRequest,
|
|
97
128
|
registry: EmailRegistry,
|
|
98
129
|
documents: DocumentSupport<Registry> | null,
|
|
99
|
-
): Promise<RenderResult> {
|
|
130
|
+
): Promise<RenderResult | ParseResult> {
|
|
100
131
|
if (isDocumentRequest(request)) {
|
|
101
132
|
if (!documents) throw new Error("React email document rendering is not enabled")
|
|
102
133
|
return documents.compose(request, documents.registry)
|
|
103
134
|
}
|
|
104
135
|
|
|
136
|
+
if (isParseRequest(request)) {
|
|
137
|
+
if (!documents) throw new Error("React email document rendering is not enabled")
|
|
138
|
+
return documents.parse(request, documents.registry)
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
return renderEmail(request, registry)
|
|
106
142
|
}
|
|
107
143
|
|
|
@@ -121,7 +157,10 @@ export async function serve<Registry = unknown>(
|
|
|
121
157
|
|
|
122
158
|
const write = isolateStdout()
|
|
123
159
|
try {
|
|
124
|
-
const request = JSON.parse(await readStdin()) as
|
|
160
|
+
const request = JSON.parse(await readStdin()) as
|
|
161
|
+
| RenderRequest
|
|
162
|
+
| RenderDocumentRequest
|
|
163
|
+
| ParseDocumentRequest
|
|
125
164
|
write(
|
|
126
165
|
JSON.stringify({
|
|
127
166
|
...(await renderRequest(request, registry, documents)),
|
|
@@ -134,14 +173,16 @@ export async function serve<Registry = unknown>(
|
|
|
134
173
|
}
|
|
135
174
|
}
|
|
136
175
|
|
|
137
|
-
// Reserve stdout for the JSON render protocol
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// a response frame. Returns the writer to use for protocol output.
|
|
176
|
+
// Reserve stdout for the JSON render protocol: stray writes (e.g. console.log, which Node
|
|
177
|
+
// routes through process.stdout.write) are diverted to stderr so they can't corrupt a frame.
|
|
178
|
+
// Returns the writer to use for protocol output.
|
|
141
179
|
function isolateStdout(): (chunk: string) => boolean {
|
|
142
180
|
const protocolWrite = process.stdout.write.bind(process.stdout)
|
|
143
|
-
|
|
144
|
-
|
|
181
|
+
// Forward encoding/callback (and the function-as-second-arg overload), not just chunk.
|
|
182
|
+
process.stdout.write = ((chunk, encoding, callback) =>
|
|
183
|
+
typeof encoding === "function"
|
|
184
|
+
? process.stderr.write(chunk, encoding)
|
|
185
|
+
: process.stderr.write(chunk, encoding, callback)) as typeof process.stdout.write
|
|
145
186
|
return (chunk) => protocolWrite(chunk)
|
|
146
187
|
}
|
|
147
188
|
|
|
@@ -186,7 +227,11 @@ async function writePersistentResponse<Registry>(
|
|
|
186
227
|
write: (chunk: string) => boolean,
|
|
187
228
|
): Promise<void> {
|
|
188
229
|
try {
|
|
189
|
-
const request = JSON.parse(line) as
|
|
230
|
+
const request = JSON.parse(line) as
|
|
231
|
+
| RenderRequest
|
|
232
|
+
| RenderDocumentRequest
|
|
233
|
+
| ParseDocumentRequest
|
|
234
|
+
| HealthRequest
|
|
190
235
|
if (isHealthRequest(request)) {
|
|
191
236
|
write(`${JSON.stringify(okResponse())}\n`)
|
|
192
237
|
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.4.0"
|
|
2
|
+
export const RENDER_PROTOCOL_VERSION = 3
|