react-email-rails 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/build.mjs CHANGED
@@ -51,8 +51,6 @@ const { userConfig, plugin, vite } = await loadReactEmailRailsConfig({
51
51
  configLoader,
52
52
  })
53
53
 
54
- // Same isolation as the dev renderer: keep component resolve/transforms but leave unrelated
55
- // app plugins out of the email environment so their hooks can't break SSR output.
56
54
  const builder = await createBuilder(
57
55
  isolatedViteConfig(userConfig, vite, {
58
56
  root: root ?? userConfig.root,
package/bin/config.mjs CHANGED
File without changes
package/bin/dev.mjs CHANGED
@@ -31,14 +31,12 @@ const divertStdout = () => {
31
31
  }
32
32
  }
33
33
 
34
- // Load only this plugin and aliases; host dev-server plugins have global side effects.
35
34
  const restoreStdout = divertStdout()
36
35
  const { userConfig, plugin, vite } = await loadReactEmailRailsConfig({
37
36
  command: "serve",
38
37
  mode: "development",
39
38
  })
40
39
 
41
- // Forward only component resolve/compile config, so dev rendering stays close to the build.
42
40
  const server = await createServer(
43
41
  isolatedViteConfig(userConfig, vite, {
44
42
  configFile: false,
@@ -50,7 +48,6 @@ const server = await createServer(
50
48
  }),
51
49
  )
52
50
 
53
- // Render through the same `email` environment as the production build, so the two match.
54
51
  const environment = server.environments.email
55
52
  if (!isRunnableDevEnvironment(environment)) {
56
53
  await server.close()
@@ -59,7 +56,6 @@ if (!isRunnableDevEnvironment(environment)) {
59
56
 
60
57
  try {
61
58
  const { run } = await environment.runner.import("virtual:react-email-rails/server")
62
- // Restore before run(): serve() re-isolates stdout with its own protocol writer.
63
59
  restoreStdout()
64
60
  await run()
65
61
  } finally {
package/bin/shared.mjs CHANGED
@@ -2,10 +2,8 @@ import { loadConfigFromFile, mergeConfig } from "vite"
2
2
 
3
3
  import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
4
4
 
5
- // Wire contract: must match the Symbol.for(...) keys in src/index.ts.
6
5
  const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
7
6
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
8
- // Must mirror ReactEmailRailsViteOptions in src/index.ts and the option list in README.md.
9
7
  const EMAIL_VITE_CONFIG_KEYS = [
10
8
  "assetsInclude",
11
9
  "css",
@@ -17,12 +15,15 @@ const EMAIL_VITE_CONFIG_KEYS = [
17
15
  "resolve",
18
16
  ]
19
17
 
20
- // Emit the version/protocol handshake and exit on --health. Shared by the build and dev bins.
21
18
  export function exitIfHealthCheck() {
22
19
  if (!process.argv.includes("--health")) return
23
20
 
24
21
  process.stdout.write(
25
- JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
22
+ JSON.stringify({
23
+ ok: true,
24
+ protocolVersion: RENDER_PROTOCOL_VERSION,
25
+ packageVersion: VERSION,
26
+ }),
26
27
  )
27
28
  process.exit(0)
28
29
  }
package/dist/document.js CHANGED
@@ -2,13 +2,9 @@ import { EmailNode, composeReactEmail } from "@react-email/editor/core";
2
2
  import { StarterKit } from "@react-email/editor/extensions";
3
3
  import { EmailTheming } from "@react-email/editor/plugins";
4
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
5
  const STRUCTURAL_NODE_TYPES = new Set(resolveExtensions([StarterKit, EmailTheming])
8
6
  .filter((extension) => extension.type === "node" && !(extension instanceof EmailNode))
9
7
  .map((extension) => extension.name));
10
- // composeReactEmail renders a node as null when no extension matches or the match isn't an
11
- // EmailNode; mirror that here so warnings catch the silent case (in-schema node, no email renderer).
12
8
  function collectDroppedNodes(document, extensions) {
13
9
  const byName = new Map();
14
10
  for (const extension of extensions)
@@ -69,11 +65,7 @@ async function loadRenderMarkdown() {
69
65
  }
70
66
  throw new Error("marked is missing the expected parse export; check the installed version");
71
67
  }
72
- // The schema whitelists nodes and attributes but never validates URI protocols, so a
73
- // javascript:/data: href on a link or button reaches content_json unchecked. Allow only safe schemes.
74
68
  const ALLOWED_URI_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
75
- // Characters browsers ignore when resolving a scheme (so "java\tscript:" runs as javascript:).
76
- // Built numerically to keep the source free of literal control characters.
77
69
  const URI_IGNORED_RANGES = [
78
70
  [0x00, 0x20],
79
71
  [0xa0, 0xa0],
@@ -89,12 +81,9 @@ const URI_IGNORED_CHARS = new RegExp("[" +
89
81
  URI_IGNORED_RANGES.map(([lo, hi]) => escapeCodePoint(lo) + "-" + escapeCodePoint(hi)).join("") +
90
82
  "]", "g");
91
83
  function hasAllowedUriScheme(uri) {
92
- // No scheme → relative/anchor/query; nothing to neutralize.
93
84
  const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(uri.replace(URI_IGNORED_CHARS, ""))?.[1];
94
85
  return scheme === undefined || ALLOWED_URI_SCHEMES.has(scheme.toLowerCase());
95
86
  }
96
- // Blank disallowed hrefs (link marks and nodes like button) in place; the tree is fresh
97
- // toJSON() output, so mutation is safe.
98
87
  function neutralizeUnsafeUris(value) {
99
88
  if (Array.isArray(value)) {
100
89
  for (const item of value)
@@ -111,7 +100,6 @@ function neutralizeUnsafeUris(value) {
111
100
  neutralizeUnsafeUris(node.marks);
112
101
  neutralizeUnsafeUris(node.content);
113
102
  }
114
- // Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
115
103
  async function resolveHtmlInput(request, dependencies) {
116
104
  const hasHtml = request.html !== undefined;
117
105
  const hasMarkdown = request.markdown !== undefined;
@@ -125,7 +113,6 @@ async function resolveHtmlInput(request, dependencies) {
125
113
  return request.html;
126
114
  }
127
115
  export async function composeDocument(request, registry) {
128
- // Fail legibly if the optional editor peers are present but their shape shifted.
129
116
  if (typeof composeReactEmail !== "function" ||
130
117
  typeof resolveExtensions !== "function" ||
131
118
  typeof getSchema !== "function") {
@@ -136,15 +123,12 @@ export async function composeDocument(request, registry) {
136
123
  const extensions = resolveExtensions(renderer.buildExtensions(request.context));
137
124
  const schema = getSchema(extensions);
138
125
  const warnings = collectDroppedNodes(document, extensions);
139
- // The minimal editor composeReactEmail reads, built headless (no DOM, no view).
140
- // state.doc is required: EmailTheming finds the globalContent theme node through it.
141
126
  const editor = {
142
127
  getJSON: () => document,
143
128
  extensionManager: { extensions },
144
129
  schema,
145
130
  state: { doc: schema.nodeFromJSON(document) },
146
131
  };
147
- // composeReactEmail takes `preview?: string`; omit it rather than pass null.
148
132
  const preview = request.preview ?? renderer.getPreview?.(request.context) ?? null;
149
133
  const params = preview === null ? { editor } : { editor, preview };
150
134
  const { html, text } = await composeReactEmail(params);
@@ -162,7 +146,6 @@ export async function parseDocument(request, registry, dependencies = {}) {
162
146
  return { document };
163
147
  }
164
148
  export function createParseDocument(generateJSON, renderMarkdown) {
165
- // Omit renderMarkdown entirely when unset; exactOptionalPropertyTypes forbids passing `undefined`.
166
149
  const dependencies = renderMarkdown === undefined ? { generateJSON } : { generateJSON, renderMarkdown };
167
150
  return (request, registry) => parseDocument(request, registry, dependencies);
168
151
  }
package/dist/index.js CHANGED
@@ -9,18 +9,12 @@ const VIRTUAL_MAIN = "virtual:react-email-rails/main";
9
9
  const RESOLVED_SERVER = `\0${VIRTUAL_SERVER}`;
10
10
  const RESOLVED_MAIN = `\0${VIRTUAL_MAIN}`;
11
11
  const VIRTUAL_MODULE_PATTERN = /virtual:react-email-rails\/(?:server|main)$/;
12
- // The dedicated build environment that emits the server-side email bundle.
13
12
  export const EMAIL_ENVIRONMENT = "email";
14
- // Wire contract: must match the Symbol.for(...) keys the bins read in bin/shared.mjs.
15
13
  const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
16
14
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite");
17
- // Must match Ruby's Configuration::BUNDLE_PATH (check_version_sync.rb asserts it).
18
15
  const OUT_DIR = "tmp/react-email-rails";
19
16
  const BUNDLE_FILE = "emails.js";
20
17
  const require = createRequire(import.meta.url);
21
- // happy-dom (via @tiptap/html) pulls in `ws`, which guards optional native-addon requires behind
22
- // these flags. Setting them lets a standalone (noExternal) build tree-shake the uninstalled
23
- // bufferutil/utf-8-validate requires away; ws's pure-JS path is all the HTML parser needs.
24
18
  const WS_NATIVE_ADDON_OPT_OUT = {
25
19
  "process.env.WS_NO_BUFFER_UTIL": "'1'",
26
20
  "process.env.WS_NO_UTF_8_VALIDATE": "'1'",
@@ -49,6 +43,13 @@ function normalizeSource(option, defaultPath, defaultExtensions) {
49
43
  const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns);
50
44
  return { path, extensions, ignore, root, globArg };
51
45
  }
46
+ function normalizePath(path) {
47
+ return path.replace(/\\/g, "/");
48
+ }
49
+ function isWithinSource(file, root, relativeDir) {
50
+ const base = `${normalizePath(root).replace(/\/+$/, "")}/${relativeDir}/`;
51
+ return normalizePath(file).startsWith(base);
52
+ }
52
53
  function optionalPeersAvailable(specifiers) {
53
54
  return specifiers.every((specifier) => {
54
55
  try {
@@ -65,6 +66,7 @@ export function reactEmailRails(options = {}) {
65
66
  const documentSource = options.documents === undefined || options.documents === false
66
67
  ? null
67
68
  : normalizeSource(options.documents === true ? undefined : options.documents, DEFAULT_DOCUMENT_PATH, DEFAULT_DOCUMENT_EXTENSIONS);
69
+ const liveReloadDirs = [emailSource.path, documentSource?.path].filter((path) => Boolean(path));
68
70
  const standalone = options.standalone ?? true;
69
71
  const plugin = {
70
72
  name: "react-email-rails",
@@ -83,7 +85,6 @@ export function reactEmailRails(options = {}) {
83
85
  if (id === RESOLVED_SERVER) {
84
86
  const lines = [`import { buildRegistry, serve } from "react-email-rails/runtime"`];
85
87
  const parserPeersAvailable = documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"]);
86
- // Markdown parsing layers `marked` on top of the HTML parser peers.
87
88
  const markdownPeerAvailable = parserPeersAvailable && optionalPeersAvailable(["marked"]);
88
89
  if (documentSource) {
89
90
  lines.push(parserPeersAvailable
@@ -109,10 +110,16 @@ export function reactEmailRails(options = {}) {
109
110
  }
110
111
  },
111
112
  },
113
+ hotUpdate(options) {
114
+ const root = options.server.config.root;
115
+ if (!liveReloadDirs.some((dir) => isWithinSource(options.file, root, dir)))
116
+ return;
117
+ if (this.environment.name !== "client")
118
+ return;
119
+ this.environment.hot.send({ type: "full-reload" });
120
+ return [];
121
+ },
112
122
  config(_config, env) {
113
- // Dedicated `email` build environment: the react-email-rails-build bin builds it with an
114
- // isolated plugin stack so host plugins can't break email SSR. Standalone builds inline
115
- // Node deps (so Rails images need no node_modules); dev keeps them external for the runner.
116
123
  return {
117
124
  environments: {
118
125
  [EMAIL_ENVIRONMENT]: {
package/dist/runtime.js CHANGED
@@ -20,8 +20,14 @@ export async function renderEmail(request, registry) {
20
20
  const mod = typeof loader === "function" ? await loader() : loader;
21
21
  const element = React.createElement(mod.default, request.props ?? {});
22
22
  return {
23
- html: await render(element, { ...request.renderOptions?.html, plainText: false }),
24
- text: await render(element, { ...request.renderOptions?.text, plainText: true }),
23
+ html: await render(element, {
24
+ ...request.renderOptions?.html,
25
+ plainText: false,
26
+ }),
27
+ text: await render(element, {
28
+ ...request.renderOptions?.text,
29
+ plainText: true,
30
+ }),
25
31
  };
26
32
  }
27
33
  function isDocumentRequest(request) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.6.0";
1
+ export declare const VERSION = "0.7.0";
2
2
  export declare const RENDER_PROTOCOL_VERSION = 3;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.6.0";
1
+ export const VERSION = "0.7.0";
2
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.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Build and send emails using React and Rails",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,10 +50,11 @@
50
50
  "check:version": "ruby ../scripts/check_version_sync.rb",
51
51
  "lint": "oxlint src test bin",
52
52
  "format": "oxfmt src test bin",
53
+ "check:format": "oxfmt --check src test bin",
53
54
  "typecheck": "tsgo -p tsconfig.json --noEmit",
54
55
  "test": "vitest run",
55
56
  "sync:version": "ruby ../scripts/sync_version.rb",
56
- "ci": "pnpm run check:version && pnpm build && pnpm lint && pnpm typecheck && pnpm test",
57
+ "ci": "pnpm run check:version && pnpm run check:format && pnpm build && pnpm lint && pnpm typecheck && pnpm test",
57
58
  "prepack": "pnpm run sync:version && pnpm build",
58
59
  "prepublishOnly": "pnpm run check:version"
59
60
  },
package/src/document.ts CHANGED
@@ -12,7 +12,6 @@ import type {
12
12
  RenderResult,
13
13
  } from "./runtime.js"
14
14
 
15
- // Re-exported from runtime.ts (the single source) to keep react-email-rails/document's surface.
16
15
  export type { DroppedNode, ParseDocumentRequest, RenderDocumentRequest }
17
16
 
18
17
  export type DocumentRenderer = {
@@ -21,16 +20,12 @@ export type DocumentRenderer = {
21
20
  getPreview?: (context: unknown) => string | null
22
21
  }
23
22
 
24
- // Editor-bundled structural nodes render to null by design. Derive the list from
25
- // the installed editor package so warning filtering tracks version changes.
26
23
  const STRUCTURAL_NODE_TYPES: ReadonlySet<string> = new Set(
27
24
  resolveExtensions([StarterKit, EmailTheming])
28
25
  .filter((extension) => extension.type === "node" && !(extension instanceof EmailNode))
29
26
  .map((extension) => extension.name),
30
27
  )
31
28
 
32
- // composeReactEmail renders a node as null when no extension matches or the match isn't an
33
- // EmailNode; mirror that here so warnings catch the silent case (in-schema node, no email renderer).
34
29
  function collectDroppedNodes(document: unknown, extensions: Extensions): DroppedNode[] {
35
30
  const byName = new Map<string, Extensions[number]>()
36
31
  for (const extension of extensions) byName.set(extension.name, extension)
@@ -62,7 +57,6 @@ export type DocumentRegistry = Record<string, DocumentLoader>
62
57
  type GenerateJSON = (html: string, extensions: Extensions) => unknown
63
58
  type RenderMarkdown = (markdown: string) => string | Promise<string>
64
59
 
65
- // Bound at build time by createParseDocument when the peers are bundled; lazy-loaded otherwise.
66
60
  type ParseDependencies = {
67
61
  generateJSON?: GenerateJSON
68
62
  renderMarkdown?: RenderMarkdown
@@ -120,12 +114,8 @@ async function loadRenderMarkdown(): Promise<RenderMarkdown> {
120
114
  throw new Error("marked is missing the expected parse export; check the installed version")
121
115
  }
122
116
 
123
- // The schema whitelists nodes and attributes but never validates URI protocols, so a
124
- // javascript:/data: href on a link or button reaches content_json unchecked. Allow only safe schemes.
125
117
  const ALLOWED_URI_SCHEMES: ReadonlySet<string> = new Set(["http", "https", "mailto", "tel"])
126
118
 
127
- // Characters browsers ignore when resolving a scheme (so "java\tscript:" runs as javascript:).
128
- // Built numerically to keep the source free of literal control characters.
129
119
  const URI_IGNORED_RANGES: ReadonlyArray<readonly [number, number]> = [
130
120
  [0x00, 0x20],
131
121
  [0xa0, 0xa0],
@@ -145,14 +135,11 @@ const URI_IGNORED_CHARS = new RegExp(
145
135
  )
146
136
 
147
137
  function hasAllowedUriScheme(uri: string): boolean {
148
- // No scheme → relative/anchor/query; nothing to neutralize.
149
138
  const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(uri.replace(URI_IGNORED_CHARS, ""))?.[1]
150
139
 
151
140
  return scheme === undefined || ALLOWED_URI_SCHEMES.has(scheme.toLowerCase())
152
141
  }
153
142
 
154
- // Blank disallowed hrefs (link marks and nodes like button) in place; the tree is fresh
155
- // toJSON() output, so mutation is safe.
156
143
  function neutralizeUnsafeUris(value: unknown): void {
157
144
  if (Array.isArray(value)) {
158
145
  for (const item of value) neutralizeUnsafeUris(item)
@@ -176,7 +163,6 @@ function neutralizeUnsafeUris(value: unknown): void {
176
163
  neutralizeUnsafeUris(node.content)
177
164
  }
178
165
 
179
- // Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
180
166
  async function resolveHtmlInput(
181
167
  request: ParseDocumentRequest,
182
168
  dependencies: ParseDependencies,
@@ -199,7 +185,6 @@ export async function composeDocument(
199
185
  request: RenderDocumentRequest,
200
186
  registry: DocumentRegistry,
201
187
  ): Promise<RenderResult> {
202
- // Fail legibly if the optional editor peers are present but their shape shifted.
203
188
  if (
204
189
  typeof composeReactEmail !== "function" ||
205
190
  typeof resolveExtensions !== "function" ||
@@ -218,8 +203,6 @@ export async function composeDocument(
218
203
  const schema = getSchema(extensions)
219
204
  const warnings = collectDroppedNodes(document, extensions)
220
205
 
221
- // The minimal editor composeReactEmail reads, built headless (no DOM, no view).
222
- // state.doc is required: EmailTheming finds the globalContent theme node through it.
223
206
  const editor = {
224
207
  getJSON: () => document,
225
208
  extensionManager: { extensions },
@@ -227,11 +210,10 @@ export async function composeDocument(
227
210
  state: { doc: schema.nodeFromJSON(document) },
228
211
  } as unknown as Editor
229
212
 
230
- // composeReactEmail takes `preview?: string`; omit it rather than pass null.
231
213
  const preview = request.preview ?? renderer.getPreview?.(request.context) ?? null
232
214
  const params = preview === null ? { editor } : { editor, preview }
233
-
234
215
  const { html, text } = await composeReactEmail(params)
216
+
235
217
  return warnings.length > 0 ? { html, text, warnings } : { html, text }
236
218
  }
237
219
 
@@ -243,18 +225,17 @@ export async function parseDocument(
243
225
  const renderer = await resolveRenderer(request.type, registry)
244
226
  const extensions = resolveExtensions(renderer.buildExtensions(request.context))
245
227
  const schema = getSchema(extensions)
246
-
247
228
  const html = await resolveHtmlInput(request, dependencies)
248
229
  const parseHTML = dependencies.generateJSON ?? (await loadGenerateJSON())
249
230
  const parsed = parseHTML(html, extensions)
250
231
  const document = schema.nodeFromJSON(parsed).toJSON()
232
+
251
233
  neutralizeUnsafeUris(document)
252
234
 
253
235
  return { document }
254
236
  }
255
237
 
256
238
  export function createParseDocument(generateJSON: GenerateJSON, renderMarkdown?: RenderMarkdown) {
257
- // Omit renderMarkdown entirely when unset; exactOptionalPropertyTypes forbids passing `undefined`.
258
239
  const dependencies: ParseDependencies =
259
240
  renderMarkdown === undefined ? { generateJSON } : { generateJSON, renderMarkdown }
260
241
  return (request: ParseDocumentRequest, registry: DocumentRegistry): Promise<ParseResult> =>
package/src/index.ts CHANGED
@@ -12,7 +12,6 @@ export type EmailsOption =
12
12
 
13
13
  export type ReactEmailRailsOptions = {
14
14
  emails?: EmailsOption
15
- // Editor document renderers, discovered like emails. Off by default.
16
15
  documents?: EmailsOption | boolean
17
16
  standalone?: boolean
18
17
  vite?: ReactEmailRailsViteOptions
@@ -59,19 +58,13 @@ const RESOLVED_SERVER = `\0${VIRTUAL_SERVER}`
59
58
  const RESOLVED_MAIN = `\0${VIRTUAL_MAIN}`
60
59
  const VIRTUAL_MODULE_PATTERN = /virtual:react-email-rails\/(?:server|main)$/
61
60
 
62
- // The dedicated build environment that emits the server-side email bundle.
63
61
  export const EMAIL_ENVIRONMENT = "email"
64
- // Wire contract: must match the Symbol.for(...) keys the bins read in bin/shared.mjs.
65
62
  const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
66
63
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
67
- // Must match Ruby's Configuration::BUNDLE_PATH (check_version_sync.rb asserts it).
68
64
  const OUT_DIR = "tmp/react-email-rails"
69
65
  const BUNDLE_FILE = "emails.js"
70
66
  const require = createRequire(import.meta.url)
71
67
 
72
- // happy-dom (via @tiptap/html) pulls in `ws`, which guards optional native-addon requires behind
73
- // these flags. Setting them lets a standalone (noExternal) build tree-shake the uninstalled
74
- // bufferutil/utf-8-validate requires away; ws's pure-JS path is all the HTML parser needs.
75
68
  const WS_NATIVE_ADDON_OPT_OUT = {
76
69
  "process.env.WS_NO_BUFFER_UTIL": "'1'",
77
70
  "process.env.WS_NO_UTF_8_VALIDATE": "'1'",
@@ -113,6 +106,15 @@ function normalizeSource(
113
106
  return { path, extensions, ignore, root, globArg }
114
107
  }
115
108
 
109
+ function normalizePath(path: string): string {
110
+ return path.replace(/\\/g, "/")
111
+ }
112
+
113
+ function isWithinSource(file: string, root: string, relativeDir: string): boolean {
114
+ const base = `${normalizePath(root).replace(/\/+$/, "")}/${relativeDir}/`
115
+ return normalizePath(file).startsWith(base)
116
+ }
117
+
116
118
  function optionalPeersAvailable(specifiers: string[]): boolean {
117
119
  return specifiers.every((specifier) => {
118
120
  try {
@@ -134,6 +136,9 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
134
136
  DEFAULT_DOCUMENT_PATH,
135
137
  DEFAULT_DOCUMENT_EXTENSIONS,
136
138
  )
139
+ const liveReloadDirs = [emailSource.path, documentSource?.path].filter((path): path is string =>
140
+ Boolean(path),
141
+ )
137
142
  const standalone = options.standalone ?? true
138
143
 
139
144
  const plugin: Plugin = {
@@ -154,7 +159,6 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
154
159
  const lines = [`import { buildRegistry, serve } from "react-email-rails/runtime"`]
155
160
  const parserPeersAvailable =
156
161
  documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"])
157
- // Markdown parsing layers `marked` on top of the HTML parser peers.
158
162
  const markdownPeerAvailable = parserPeersAvailable && optionalPeersAvailable(["marked"])
159
163
 
160
164
  if (documentSource) {
@@ -191,10 +195,16 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
191
195
  },
192
196
  },
193
197
 
198
+ hotUpdate(options) {
199
+ const root = options.server.config.root
200
+ if (!liveReloadDirs.some((dir) => isWithinSource(options.file, root, dir))) return
201
+ if (this.environment.name !== "client") return
202
+
203
+ this.environment.hot.send({ type: "full-reload" })
204
+ return []
205
+ },
206
+
194
207
  config(_config, env: ConfigEnv) {
195
- // Dedicated `email` build environment: the react-email-rails-build bin builds it with an
196
- // isolated plugin stack so host plugins can't break email SSR. Standalone builds inline
197
- // Node deps (so Rails images need no node_modules); dev keeps them external for the runner.
198
208
  return {
199
209
  environments: {
200
210
  [EMAIL_ENVIRONMENT]: {
package/src/runtime.ts CHANGED
@@ -47,7 +47,6 @@ export type RenderDocumentRequest = {
47
47
  preview?: string | null
48
48
  }
49
49
 
50
- // Exactly one of `html` or `markdown` is set.
51
50
  export type ParseDocumentRequest = {
52
51
  kind: "parse"
53
52
  type: string
@@ -108,8 +107,14 @@ export async function renderEmail(
108
107
  const element = React.createElement(mod.default, request.props ?? {})
109
108
 
110
109
  return {
111
- html: await render(element, { ...request.renderOptions?.html, plainText: false }),
112
- text: await render(element, { ...request.renderOptions?.text, plainText: true }),
110
+ html: await render(element, {
111
+ ...request.renderOptions?.html,
112
+ plainText: false,
113
+ }),
114
+ text: await render(element, {
115
+ ...request.renderOptions?.text,
116
+ plainText: true,
117
+ }),
113
118
  }
114
119
  }
115
120
 
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.6.0"
1
+ export const VERSION = "0.7.0"
2
2
  export const RENDER_PROTOCOL_VERSION = 3