react-email-rails 0.1.3 → 0.3.0

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