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.
@@ -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 loader = registry[request.type];
46
- if (!loader)
47
- throw new Error(`React email document renderer not found: ${request.type}`);
48
- const renderer = typeof loader === "function" ? await loader() : loader;
49
- if (typeof renderer.buildExtensions !== "function") {
50
- throw new Error(`React email document renderer must export a buildExtensions function: ${request.type}`);
51
- }
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
- // Imported only here, so the editor stays out of the email build graph when off.
64
- if (documentSource)
65
- lines.push(`import { composeDocument } from "react-email-rails/document"`);
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" ? { resolve: { noExternal: true } } : {}),
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.0";
2
- export declare const RENDER_PROTOCOL_VERSION = 2;
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.0";
2
- export const RENDER_PROTOCOL_VERSION = 2;
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.2.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 loader = registry[request.type]
80
- if (!loader) throw new Error(`React email document renderer not found: ${request.type}`)
81
-
82
- const renderer = typeof loader === "function" ? await loader() : loader
83
- if (typeof renderer.buildExtensions !== "function") {
84
- throw new Error(
85
- `React email document renderer must export a buildExtensions function: ${request.type}`,
86
- )
87
- }
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
- // Imported only here, so the editor stays out of the email build graph when off.
134
- if (documentSource)
135
- lines.push(`import { composeDocument } from "react-email-rails/document"`)
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" ? { resolve: { noExternal: true } } : {}),
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 RenderRequest | RenderDocumentRequest
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 RenderRequest | RenderDocumentRequest | HealthRequest
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.0"
2
- export const RENDER_PROTOCOL_VERSION = 2
1
+ export const VERSION = "0.3.0"
2
+ export const RENDER_PROTOCOL_VERSION = 3