react-email-rails 0.1.2 → 0.2.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/dev.mjs +3 -1
- package/dist/document.d.ts +18 -0
- package/dist/document.js +69 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +46 -28
- package/dist/runtime.d.ts +19 -1
- package/dist/runtime.js +28 -8
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +17 -1
- package/src/document.ts +110 -0
- package/src/index.ts +93 -30
- package/src/runtime.ts +70 -10
- package/src/version.ts +2 -2
package/bin/dev.mjs
CHANGED
|
@@ -4,7 +4,9 @@ import { fail, isolatedViteConfig, loadReactEmailRailsConfig } from "./shared.mj
|
|
|
4
4
|
import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
|
|
5
5
|
|
|
6
6
|
if (process.argv.includes("--health")) {
|
|
7
|
-
process.stdout.write(
|
|
7
|
+
process.stdout.write(
|
|
8
|
+
JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
|
|
9
|
+
)
|
|
8
10
|
process.exit(0)
|
|
9
11
|
}
|
|
10
12
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Extensions } from "@tiptap/core";
|
|
2
|
+
import type { DroppedNode, 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 declare function composeDocument(request: RenderDocumentRequest, registry: DocumentRegistry): Promise<RenderResult>;
|
package/dist/document.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
export async function composeDocument(request, registry) {
|
|
39
|
+
// Fail legibly if the optional editor peers are present but their shape shifted.
|
|
40
|
+
if (typeof composeReactEmail !== "function" ||
|
|
41
|
+
typeof resolveExtensions !== "function" ||
|
|
42
|
+
typeof getSchema !== "function") {
|
|
43
|
+
throw new Error("@react-email/editor or @tiptap/core is missing expected exports (composeReactEmail/resolveExtensions/getSchema); check the installed versions");
|
|
44
|
+
}
|
|
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
|
+
}
|
|
52
|
+
const document = renderer.transformDocument?.(request.document, request.context) ?? request.document;
|
|
53
|
+
const extensions = resolveExtensions(renderer.buildExtensions(request.context));
|
|
54
|
+
const schema = getSchema(extensions);
|
|
55
|
+
const warnings = collectDroppedNodes(document, extensions);
|
|
56
|
+
// The minimal editor composeReactEmail reads, built headless (no DOM, no view).
|
|
57
|
+
// state.doc is required: EmailTheming finds the globalContent theme node through it.
|
|
58
|
+
const editor = {
|
|
59
|
+
getJSON: () => document,
|
|
60
|
+
extensionManager: { extensions },
|
|
61
|
+
schema,
|
|
62
|
+
state: { doc: schema.nodeFromJSON(document) },
|
|
63
|
+
};
|
|
64
|
+
// composeReactEmail takes `preview?: string`; omit it rather than pass null.
|
|
65
|
+
const preview = request.preview ?? renderer.getPreview?.(request.context) ?? null;
|
|
66
|
+
const params = preview === null ? { editor } : { editor, preview };
|
|
67
|
+
const { html, text } = await composeReactEmail(params);
|
|
68
|
+
return warnings.length > 0 ? { html, text, warnings } : { html, text };
|
|
69
|
+
}
|
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,8 @@
|
|
|
1
1
|
const DEFAULT_IGNORE = ["**/_*", "**/_*/**"];
|
|
2
|
-
const
|
|
2
|
+
const DEFAULT_EMAIL_PATH = "app/javascript/emails";
|
|
3
|
+
const DEFAULT_EMAIL_EXTENSIONS = [".tsx", ".jsx"];
|
|
4
|
+
const DEFAULT_DOCUMENT_PATH = "app/javascript/documents";
|
|
5
|
+
const DEFAULT_DOCUMENT_EXTENSIONS = [".ts", ".tsx"];
|
|
3
6
|
const VIRTUAL_SERVER = "virtual:react-email-rails/server";
|
|
4
7
|
const VIRTUAL_MAIN = "virtual:react-email-rails/main";
|
|
5
8
|
const RESOLVED_SERVER = `\0${VIRTUAL_SERVER}`;
|
|
@@ -11,29 +14,36 @@ const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
|
|
|
11
14
|
const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite");
|
|
12
15
|
const OUT_DIR = "tmp/react-email-rails";
|
|
13
16
|
const BUNDLE_FILE = "emails.js";
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const path = (
|
|
17
|
-
const rawExtensions =
|
|
18
|
-
?
|
|
19
|
-
: Array.isArray(
|
|
20
|
-
?
|
|
21
|
-
: [
|
|
17
|
+
function normalizeSource(option, defaultPath, defaultExtensions) {
|
|
18
|
+
const source = typeof option === "string" ? { path: option } : (option ?? {});
|
|
19
|
+
const path = (source.path ?? defaultPath).replace(/^\/|\/$/g, "");
|
|
20
|
+
const rawExtensions = source.extension === undefined
|
|
21
|
+
? defaultExtensions
|
|
22
|
+
: Array.isArray(source.extension)
|
|
23
|
+
? source.extension
|
|
24
|
+
: [source.extension];
|
|
22
25
|
const extensions = rawExtensions
|
|
23
26
|
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
|
|
24
27
|
.map((extension, index) => ({ extension, index }))
|
|
25
28
|
.sort((left, right) => right.extension.length - left.extension.length || left.index - right.index)
|
|
26
29
|
.map(({ extension }) => extension);
|
|
27
|
-
const standalone = options.standalone ?? true;
|
|
28
30
|
const root = `/${path}/`;
|
|
29
31
|
const pattern = extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`;
|
|
30
|
-
const ignore =
|
|
32
|
+
const ignore = source.ignore === undefined
|
|
31
33
|
? DEFAULT_IGNORE
|
|
32
|
-
: Array.isArray(
|
|
33
|
-
?
|
|
34
|
-
: [
|
|
34
|
+
: Array.isArray(source.ignore)
|
|
35
|
+
? source.ignore
|
|
36
|
+
: [source.ignore];
|
|
35
37
|
const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)];
|
|
36
38
|
const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns);
|
|
39
|
+
return { path, extensions, ignore, root, globArg };
|
|
40
|
+
}
|
|
41
|
+
export function reactEmailRails(options = {}) {
|
|
42
|
+
const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS);
|
|
43
|
+
const documentSource = options.documents === undefined || options.documents === false
|
|
44
|
+
? null
|
|
45
|
+
: normalizeSource(options.documents === true ? undefined : options.documents, DEFAULT_DOCUMENT_PATH, DEFAULT_DOCUMENT_EXTENSIONS);
|
|
46
|
+
const standalone = options.standalone ?? true;
|
|
37
47
|
const plugin = {
|
|
38
48
|
name: "react-email-rails",
|
|
39
49
|
resolveId: {
|
|
@@ -49,17 +59,18 @@ export function reactEmailRails(options = {}) {
|
|
|
49
59
|
filter: { id: VIRTUAL_MODULE_PATTERN },
|
|
50
60
|
handler(id) {
|
|
51
61
|
if (id === RESOLVED_SERVER) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
`
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
` const extension =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`export const run = () => serve(registry)
|
|
62
|
-
|
|
62
|
+
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"`);
|
|
66
|
+
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
|
+
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 })`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
lines.push(`export const run = () => serve(registry)`);
|
|
72
|
+
}
|
|
73
|
+
return lines.join("\n");
|
|
63
74
|
}
|
|
64
75
|
if (id === RESOLVED_MAIN) {
|
|
65
76
|
return `import { run } from ${JSON.stringify(VIRTUAL_SERVER)}\nrun()\n`;
|
|
@@ -94,10 +105,17 @@ export function reactEmailRails(options = {}) {
|
|
|
94
105
|
};
|
|
95
106
|
const metadata = {
|
|
96
107
|
emails: {
|
|
97
|
-
path,
|
|
98
|
-
extensions,
|
|
99
|
-
ignore,
|
|
108
|
+
path: emailSource.path,
|
|
109
|
+
extensions: emailSource.extensions,
|
|
110
|
+
ignore: emailSource.ignore,
|
|
100
111
|
},
|
|
112
|
+
...(documentSource && {
|
|
113
|
+
documents: {
|
|
114
|
+
path: documentSource.path,
|
|
115
|
+
extensions: documentSource.extensions,
|
|
116
|
+
ignore: documentSource.ignore,
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
101
119
|
standalone,
|
|
102
120
|
outDir: OUT_DIR,
|
|
103
121
|
bundleFile: BUNDLE_FILE,
|
package/dist/runtime.d.ts
CHANGED
|
@@ -17,10 +17,28 @@ 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 DroppedNode = {
|
|
28
|
+
type: string;
|
|
29
|
+
count: number;
|
|
30
|
+
};
|
|
31
|
+
export type RenderResult = RenderedEmail & {
|
|
32
|
+
warnings?: DroppedNode[];
|
|
33
|
+
};
|
|
34
|
+
export type DocumentSupport<Registry = unknown> = {
|
|
35
|
+
registry: Registry;
|
|
36
|
+
compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>;
|
|
37
|
+
};
|
|
20
38
|
export type EmailRenderOptions = {
|
|
21
39
|
html?: ReactEmailRenderOptions;
|
|
22
40
|
text?: ReactEmailRenderOptions;
|
|
23
41
|
};
|
|
24
42
|
export declare function toComponentName(globPath: string, root: string, extension: string): string;
|
|
25
43
|
export declare function renderEmail(request: RenderRequest, registry: EmailRegistry): Promise<RenderedEmail>;
|
|
26
|
-
export declare function serve(registry: EmailRegistry): Promise<void>;
|
|
44
|
+
export declare function serve<Registry = unknown>(registry: EmailRegistry, documents?: DocumentSupport<Registry> | null): Promise<void>;
|
package/dist/runtime.js
CHANGED
|
@@ -17,9 +17,26 @@ export async function renderEmail(request, registry) {
|
|
|
17
17
|
text: await render(element, { ...request.renderOptions?.text, plainText: true }),
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
function isDocumentRequest(request) {
|
|
21
|
+
return (request !== null &&
|
|
22
|
+
typeof request === "object" &&
|
|
23
|
+
request.kind === "document");
|
|
24
|
+
}
|
|
25
|
+
function isHealthRequest(request) {
|
|
26
|
+
return request !== null && typeof request === "object" && "health" in request;
|
|
27
|
+
}
|
|
28
|
+
// Requests without a document kind are component renders, preserving the email path.
|
|
29
|
+
async function renderRequest(request, registry, documents) {
|
|
30
|
+
if (isDocumentRequest(request)) {
|
|
31
|
+
if (!documents)
|
|
32
|
+
throw new Error("React email document rendering is not enabled");
|
|
33
|
+
return documents.compose(request, documents.registry);
|
|
34
|
+
}
|
|
35
|
+
return renderEmail(request, registry);
|
|
36
|
+
}
|
|
37
|
+
export async function serve(registry, documents = null) {
|
|
21
38
|
if (process.argv.includes("--persistent")) {
|
|
22
|
-
await servePersistent(registry, isolateStdout());
|
|
39
|
+
await servePersistent(registry, documents, isolateStdout());
|
|
23
40
|
return;
|
|
24
41
|
}
|
|
25
42
|
if (process.argv.includes("--health")) {
|
|
@@ -29,7 +46,10 @@ export async function serve(registry) {
|
|
|
29
46
|
const write = isolateStdout();
|
|
30
47
|
try {
|
|
31
48
|
const request = JSON.parse(await readStdin());
|
|
32
|
-
write(JSON.stringify({
|
|
49
|
+
write(JSON.stringify({
|
|
50
|
+
...(await renderRequest(request, registry, documents)),
|
|
51
|
+
...protocolMetadata(),
|
|
52
|
+
}));
|
|
33
53
|
}
|
|
34
54
|
catch (error) {
|
|
35
55
|
process.stderr.write(error instanceof Error ? error.message : "React Email render failed");
|
|
@@ -56,7 +76,7 @@ function readStdin() {
|
|
|
56
76
|
process.stdin.on("error", reject);
|
|
57
77
|
});
|
|
58
78
|
}
|
|
59
|
-
async function servePersistent(registry, write) {
|
|
79
|
+
async function servePersistent(registry, documents, write) {
|
|
60
80
|
process.stdin.setEncoding("utf8");
|
|
61
81
|
let pending = "";
|
|
62
82
|
for await (const chunk of process.stdin) {
|
|
@@ -66,19 +86,19 @@ async function servePersistent(registry, write) {
|
|
|
66
86
|
const line = pending.slice(0, separator);
|
|
67
87
|
pending = pending.slice(separator + 1);
|
|
68
88
|
if (line.trim())
|
|
69
|
-
await writePersistentResponse(line, registry, write);
|
|
89
|
+
await writePersistentResponse(line, registry, documents, write);
|
|
70
90
|
separator = pending.indexOf("\n");
|
|
71
91
|
}
|
|
72
92
|
}
|
|
73
93
|
}
|
|
74
|
-
async function writePersistentResponse(line, registry, write) {
|
|
94
|
+
async function writePersistentResponse(line, registry, documents, write) {
|
|
75
95
|
try {
|
|
76
96
|
const request = JSON.parse(line);
|
|
77
|
-
if (
|
|
97
|
+
if (isHealthRequest(request)) {
|
|
78
98
|
write(`${JSON.stringify(okResponse())}\n`);
|
|
79
99
|
return;
|
|
80
100
|
}
|
|
81
|
-
write(`${JSON.stringify({ ok: true, ...(await
|
|
101
|
+
write(`${JSON.stringify({ ok: true, ...(await renderRequest(request, registry, documents)), ...protocolMetadata() })}\n`);
|
|
82
102
|
}
|
|
83
103
|
catch (error) {
|
|
84
104
|
write(`${JSON.stringify({
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
2
|
-
export declare const RENDER_PROTOCOL_VERSION =
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
|
2
|
+
export declare const RENDER_PROTOCOL_VERSION = 2;
|
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.2.0";
|
|
2
|
+
export const RENDER_PROTOCOL_VERSION = 2;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-email-rails",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,12 +61,24 @@
|
|
|
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",
|
|
61
67
|
"react": "^18.0 || ^19.0",
|
|
62
68
|
"vite": "^7.0.0 || ^8.0.0"
|
|
63
69
|
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"@react-email/editor": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"@tiptap/core": {
|
|
75
|
+
"optional": true
|
|
76
|
+
}
|
|
77
|
+
},
|
|
64
78
|
"devDependencies": {
|
|
79
|
+
"@react-email/editor": "^1.5.3",
|
|
65
80
|
"@react-email/render": "^2.0.8",
|
|
81
|
+
"@tiptap/core": "^3",
|
|
66
82
|
"@types/node": "^25.9.1",
|
|
67
83
|
"@types/react": "^19.2.15",
|
|
68
84
|
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
package/src/document.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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, 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 async function composeDocument(
|
|
65
|
+
request: RenderDocumentRequest,
|
|
66
|
+
registry: DocumentRegistry,
|
|
67
|
+
): Promise<RenderResult> {
|
|
68
|
+
// Fail legibly if the optional editor peers are present but their shape shifted.
|
|
69
|
+
if (
|
|
70
|
+
typeof composeReactEmail !== "function" ||
|
|
71
|
+
typeof resolveExtensions !== "function" ||
|
|
72
|
+
typeof getSchema !== "function"
|
|
73
|
+
) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"@react-email/editor or @tiptap/core is missing expected exports (composeReactEmail/resolveExtensions/getSchema); check the installed versions",
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
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
|
+
}
|
|
88
|
+
|
|
89
|
+
const document =
|
|
90
|
+
renderer.transformDocument?.(request.document, request.context) ?? request.document
|
|
91
|
+
const extensions = resolveExtensions(renderer.buildExtensions(request.context))
|
|
92
|
+
const schema = getSchema(extensions)
|
|
93
|
+
const warnings = collectDroppedNodes(document, extensions)
|
|
94
|
+
|
|
95
|
+
// The minimal editor composeReactEmail reads, built headless (no DOM, no view).
|
|
96
|
+
// state.doc is required: EmailTheming finds the globalContent theme node through it.
|
|
97
|
+
const editor = {
|
|
98
|
+
getJSON: () => document,
|
|
99
|
+
extensionManager: { extensions },
|
|
100
|
+
schema,
|
|
101
|
+
state: { doc: schema.nodeFromJSON(document) },
|
|
102
|
+
} as unknown as Editor
|
|
103
|
+
|
|
104
|
+
// composeReactEmail takes `preview?: string`; omit it rather than pass null.
|
|
105
|
+
const preview = request.preview ?? renderer.getPreview?.(request.context) ?? null
|
|
106
|
+
const params = preview === null ? { editor } : { editor, preview }
|
|
107
|
+
|
|
108
|
+
const { html, text } = await composeReactEmail(params)
|
|
109
|
+
return warnings.length > 0 ? { html, text, warnings } : { html, text }
|
|
110
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,9 @@ export type EmailsOption =
|
|
|
10
10
|
|
|
11
11
|
export type ReactEmailRailsOptions = {
|
|
12
12
|
emails?: EmailsOption
|
|
13
|
+
// Editor document renderers, discovered like emails. Off by default; pass `true`
|
|
14
|
+
// to enable with defaults, or a path/options object to customize discovery.
|
|
15
|
+
documents?: EmailsOption | boolean
|
|
13
16
|
standalone?: boolean
|
|
14
17
|
vite?: ReactEmailRailsViteOptions
|
|
15
18
|
}
|
|
@@ -21,19 +24,33 @@ export type ReactEmailRailsViteOptions = Pick<
|
|
|
21
24
|
oxc?: unknown
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
type SourceMetadata = {
|
|
28
|
+
path: string
|
|
29
|
+
extensions: string[]
|
|
30
|
+
ignore: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
type PluginMetadata = {
|
|
25
|
-
emails:
|
|
26
|
-
|
|
27
|
-
extensions: string[]
|
|
28
|
-
ignore: string[]
|
|
29
|
-
}
|
|
34
|
+
emails: SourceMetadata
|
|
35
|
+
documents?: SourceMetadata
|
|
30
36
|
standalone: boolean
|
|
31
37
|
outDir: string
|
|
32
38
|
bundleFile: string
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
type Source = {
|
|
42
|
+
path: string
|
|
43
|
+
extensions: string[]
|
|
44
|
+
ignore: string[]
|
|
45
|
+
root: string
|
|
46
|
+
globArg: string
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
const DEFAULT_IGNORE = ["**/_*", "**/_*/**"]
|
|
36
|
-
const
|
|
50
|
+
const DEFAULT_EMAIL_PATH = "app/javascript/emails"
|
|
51
|
+
const DEFAULT_EMAIL_EXTENSIONS = [".tsx", ".jsx"]
|
|
52
|
+
const DEFAULT_DOCUMENT_PATH = "app/javascript/documents"
|
|
53
|
+
const DEFAULT_DOCUMENT_EXTENSIONS = [".ts", ".tsx"]
|
|
37
54
|
|
|
38
55
|
const VIRTUAL_SERVER = "virtual:react-email-rails/server"
|
|
39
56
|
const VIRTUAL_MAIN = "virtual:react-email-rails/main"
|
|
@@ -48,16 +65,19 @@ const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
|
|
|
48
65
|
const OUT_DIR = "tmp/react-email-rails"
|
|
49
66
|
const BUNDLE_FILE = "emails.js"
|
|
50
67
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
function normalizeSource(
|
|
69
|
+
option: EmailsOption | undefined,
|
|
70
|
+
defaultPath: string,
|
|
71
|
+
defaultExtensions: string[],
|
|
72
|
+
): Source {
|
|
73
|
+
const source = typeof option === "string" ? { path: option } : (option ?? {})
|
|
74
|
+
const path = (source.path ?? defaultPath).replace(/^\/|\/$/g, "")
|
|
55
75
|
const rawExtensions =
|
|
56
|
-
|
|
57
|
-
?
|
|
58
|
-
: Array.isArray(
|
|
59
|
-
?
|
|
60
|
-
: [
|
|
76
|
+
source.extension === undefined
|
|
77
|
+
? defaultExtensions
|
|
78
|
+
: Array.isArray(source.extension)
|
|
79
|
+
? source.extension
|
|
80
|
+
: [source.extension]
|
|
61
81
|
const extensions = rawExtensions
|
|
62
82
|
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
|
|
63
83
|
.map((extension, index) => ({ extension, index }))
|
|
@@ -65,20 +85,34 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
65
85
|
(left, right) => right.extension.length - left.extension.length || left.index - right.index,
|
|
66
86
|
)
|
|
67
87
|
.map(({ extension }) => extension)
|
|
68
|
-
const standalone = options.standalone ?? true
|
|
69
88
|
|
|
70
89
|
const root = `/${path}/`
|
|
71
90
|
const pattern =
|
|
72
91
|
extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`
|
|
73
92
|
const ignore =
|
|
74
|
-
|
|
93
|
+
source.ignore === undefined
|
|
75
94
|
? DEFAULT_IGNORE
|
|
76
|
-
: Array.isArray(
|
|
77
|
-
?
|
|
78
|
-
: [
|
|
95
|
+
: Array.isArray(source.ignore)
|
|
96
|
+
? source.ignore
|
|
97
|
+
: [source.ignore]
|
|
79
98
|
const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)]
|
|
80
99
|
const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns)
|
|
81
100
|
|
|
101
|
+
return { path, extensions, ignore, root, globArg }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
105
|
+
const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS)
|
|
106
|
+
const documentSource =
|
|
107
|
+
options.documents === undefined || options.documents === false
|
|
108
|
+
? null
|
|
109
|
+
: normalizeSource(
|
|
110
|
+
options.documents === true ? undefined : options.documents,
|
|
111
|
+
DEFAULT_DOCUMENT_PATH,
|
|
112
|
+
DEFAULT_DOCUMENT_EXTENSIONS,
|
|
113
|
+
)
|
|
114
|
+
const standalone = options.standalone ?? true
|
|
115
|
+
|
|
82
116
|
const plugin: Plugin = {
|
|
83
117
|
name: "react-email-rails",
|
|
84
118
|
|
|
@@ -94,17 +128,38 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
94
128
|
filter: { id: VIRTUAL_MODULE_PATTERN },
|
|
95
129
|
handler(id) {
|
|
96
130
|
if (id === RESOLVED_SERVER) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
131
|
+
const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`]
|
|
132
|
+
|
|
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"`)
|
|
136
|
+
|
|
137
|
+
lines.push(
|
|
138
|
+
`const modules = import.meta.glob(${emailSource.globArg})`,
|
|
139
|
+
`const extensions = ${JSON.stringify(emailSource.extensions)}`,
|
|
101
140
|
`const registry = Object.create(null)`,
|
|
102
141
|
`for (const path in modules) {`,
|
|
103
142
|
` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
|
|
104
|
-
` registry[toComponentName(path, ${JSON.stringify(root)}, extension)] = modules[path]`,
|
|
143
|
+
` registry[toComponentName(path, ${JSON.stringify(emailSource.root)}, extension)] = modules[path]`,
|
|
105
144
|
`}`,
|
|
106
|
-
|
|
107
|
-
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if (documentSource) {
|
|
148
|
+
lines.push(
|
|
149
|
+
`const documentModules = import.meta.glob(${documentSource.globArg})`,
|
|
150
|
+
`const documentExtensions = ${JSON.stringify(documentSource.extensions)}`,
|
|
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 })`,
|
|
157
|
+
)
|
|
158
|
+
} else {
|
|
159
|
+
lines.push(`export const run = () => serve(registry)`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return lines.join("\n")
|
|
108
163
|
}
|
|
109
164
|
|
|
110
165
|
if (id === RESOLVED_MAIN) {
|
|
@@ -142,10 +197,17 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
|
|
|
142
197
|
|
|
143
198
|
const metadata: PluginMetadata = {
|
|
144
199
|
emails: {
|
|
145
|
-
path,
|
|
146
|
-
extensions,
|
|
147
|
-
ignore,
|
|
200
|
+
path: emailSource.path,
|
|
201
|
+
extensions: emailSource.extensions,
|
|
202
|
+
ignore: emailSource.ignore,
|
|
148
203
|
},
|
|
204
|
+
...(documentSource && {
|
|
205
|
+
documents: {
|
|
206
|
+
path: documentSource.path,
|
|
207
|
+
extensions: documentSource.extensions,
|
|
208
|
+
ignore: documentSource.ignore,
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
149
211
|
standalone,
|
|
150
212
|
outDir: OUT_DIR,
|
|
151
213
|
bundleFile: BUNDLE_FILE,
|
|
@@ -169,5 +231,6 @@ export type {
|
|
|
169
231
|
EmailRenderOptions,
|
|
170
232
|
RenderedEmail,
|
|
171
233
|
RenderRequest,
|
|
234
|
+
RenderResult,
|
|
172
235
|
} from "./runtime.js"
|
|
173
236
|
export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js"
|
package/src/runtime.ts
CHANGED
|
@@ -25,6 +25,28 @@ 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
|
+
// A document node type that rendered to nothing, with how many times it occurred.
|
|
37
|
+
export type DroppedNode = { type: string; count: number }
|
|
38
|
+
|
|
39
|
+
// A render result plus any non-fatal warnings (document nodes dropped because no
|
|
40
|
+
// extension rendered them). Component renders never carry warnings.
|
|
41
|
+
export type RenderResult = RenderedEmail & { warnings?: DroppedNode[] }
|
|
42
|
+
|
|
43
|
+
// Injected by the generated server module when documents are enabled, so `serve`
|
|
44
|
+
// renders documents without importing the editor module or its peer types.
|
|
45
|
+
export type DocumentSupport<Registry = unknown> = {
|
|
46
|
+
registry: Registry
|
|
47
|
+
compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>
|
|
48
|
+
}
|
|
49
|
+
|
|
28
50
|
type ProtocolMetadata = {
|
|
29
51
|
protocolVersion: number
|
|
30
52
|
packageVersion: string
|
|
@@ -57,9 +79,38 @@ export async function renderEmail(
|
|
|
57
79
|
}
|
|
58
80
|
}
|
|
59
81
|
|
|
60
|
-
|
|
82
|
+
function isDocumentRequest(request: unknown): request is RenderDocumentRequest {
|
|
83
|
+
return (
|
|
84
|
+
request !== null &&
|
|
85
|
+
typeof request === "object" &&
|
|
86
|
+
(request as { kind?: unknown }).kind === "document"
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isHealthRequest(request: unknown): request is HealthRequest {
|
|
91
|
+
return request !== null && typeof request === "object" && "health" in request
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Requests without a document kind are component renders, preserving the email path.
|
|
95
|
+
async function renderRequest<Registry>(
|
|
96
|
+
request: RenderRequest | RenderDocumentRequest,
|
|
97
|
+
registry: EmailRegistry,
|
|
98
|
+
documents: DocumentSupport<Registry> | null,
|
|
99
|
+
): Promise<RenderResult> {
|
|
100
|
+
if (isDocumentRequest(request)) {
|
|
101
|
+
if (!documents) throw new Error("React email document rendering is not enabled")
|
|
102
|
+
return documents.compose(request, documents.registry)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return renderEmail(request, registry)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function serve<Registry = unknown>(
|
|
109
|
+
registry: EmailRegistry,
|
|
110
|
+
documents: DocumentSupport<Registry> | null = null,
|
|
111
|
+
): Promise<void> {
|
|
61
112
|
if (process.argv.includes("--persistent")) {
|
|
62
|
-
await servePersistent(registry, isolateStdout())
|
|
113
|
+
await servePersistent(registry, documents, isolateStdout())
|
|
63
114
|
return
|
|
64
115
|
}
|
|
65
116
|
|
|
@@ -70,8 +121,13 @@ export async function serve(registry: EmailRegistry): Promise<void> {
|
|
|
70
121
|
|
|
71
122
|
const write = isolateStdout()
|
|
72
123
|
try {
|
|
73
|
-
const request = JSON.parse(await readStdin()) as RenderRequest
|
|
74
|
-
write(
|
|
124
|
+
const request = JSON.parse(await readStdin()) as RenderRequest | RenderDocumentRequest
|
|
125
|
+
write(
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
...(await renderRequest(request, registry, documents)),
|
|
128
|
+
...protocolMetadata(),
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
75
131
|
} catch (error) {
|
|
76
132
|
process.stderr.write(error instanceof Error ? error.message : "React Email render failed")
|
|
77
133
|
process.exitCode = 1
|
|
@@ -101,8 +157,9 @@ function readStdin(): Promise<string> {
|
|
|
101
157
|
})
|
|
102
158
|
}
|
|
103
159
|
|
|
104
|
-
async function servePersistent(
|
|
160
|
+
async function servePersistent<Registry>(
|
|
105
161
|
registry: EmailRegistry,
|
|
162
|
+
documents: DocumentSupport<Registry> | null,
|
|
106
163
|
write: (chunk: string) => boolean,
|
|
107
164
|
): Promise<void> {
|
|
108
165
|
process.stdin.setEncoding("utf8")
|
|
@@ -116,25 +173,28 @@ async function servePersistent(
|
|
|
116
173
|
const line = pending.slice(0, separator)
|
|
117
174
|
pending = pending.slice(separator + 1)
|
|
118
175
|
|
|
119
|
-
if (line.trim()) await writePersistentResponse(line, registry, write)
|
|
176
|
+
if (line.trim()) await writePersistentResponse(line, registry, documents, write)
|
|
120
177
|
separator = pending.indexOf("\n")
|
|
121
178
|
}
|
|
122
179
|
}
|
|
123
180
|
}
|
|
124
181
|
|
|
125
|
-
async function writePersistentResponse(
|
|
182
|
+
async function writePersistentResponse<Registry>(
|
|
126
183
|
line: string,
|
|
127
184
|
registry: EmailRegistry,
|
|
185
|
+
documents: DocumentSupport<Registry> | null,
|
|
128
186
|
write: (chunk: string) => boolean,
|
|
129
187
|
): Promise<void> {
|
|
130
188
|
try {
|
|
131
|
-
const request = JSON.parse(line) as RenderRequest | HealthRequest
|
|
132
|
-
if (
|
|
189
|
+
const request = JSON.parse(line) as RenderRequest | RenderDocumentRequest | HealthRequest
|
|
190
|
+
if (isHealthRequest(request)) {
|
|
133
191
|
write(`${JSON.stringify(okResponse())}\n`)
|
|
134
192
|
return
|
|
135
193
|
}
|
|
136
194
|
|
|
137
|
-
write(
|
|
195
|
+
write(
|
|
196
|
+
`${JSON.stringify({ ok: true, ...(await renderRequest(request, registry, documents)), ...protocolMetadata() })}\n`,
|
|
197
|
+
)
|
|
138
198
|
} catch (error) {
|
|
139
199
|
write(
|
|
140
200
|
`${JSON.stringify({
|
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.2.0"
|
|
2
|
+
export const RENDER_PROTOCOL_VERSION = 2
|