react-email-rails 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8de13aced1b8b88a622cdc40577ec17c80ad457cee75ea57266dc248e5355416
4
- data.tar.gz: d42cacd94f77a32ccf75d72b5e8c19506549902c64617a723c6617e0bc613a58
3
+ metadata.gz: 1ada860a1bfdc970fd871cb2f0d4b89642e42b8641b8d11006ca66e9bf6f452a
4
+ data.tar.gz: 742b838451251d7b97093077ff9909e3d60441e705a841fed522d1d64df83787
5
5
  SHA512:
6
- metadata.gz: 1fbbbb75215e8b4b77de4d4c15e533348e2b7195196d5b9401541596e24507b3c046ca8f74998f20b76f9fbf6d9b67d8c0ec29a86af3aaa64e0b1b70bece4c85
7
- data.tar.gz: 5e7025b1552256c5d1b2fd01a911c7d941b2fd41aa6cd94ddd646fbeb79713888dd6dc13785ff71c66cadb9362877e2abe4715855630813314e7073f509ccccc
6
+ metadata.gz: 2d7490d1c6bccdd3d7d7eeb5fa0f0a1d6a9888e6090bf560b2d63feab8db4edec448eb7ba6ddbc34134ed9cf1cd34e4a2cb229bf41794c44faefbafe0f98adc0
7
+ data.tar.gz: c2a55b22219041afe9cc25f00fa5b2ef222f4d45656027c4e971b2666d90a68f02aae4894fc30f3771b3064e41b5c880febead6d79d4cecadb0cb5bd1736e3de
data/CHANGELOG.md CHANGED
@@ -1,13 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - `ReactEmailRails.parse` now accepts `markdown:` as an alternative to `html:`. Markdown is converted to HTML and runs through the same extension-driven parse path, producing the same document `Hash` — handy for agent- or tool-generated content. Pass exactly one of `html:`/`markdown:`.
6
+ - Add `marked` as an optional peer dependency, required only when calling `parse` with `markdown:`. HTML parsing and compose-only rendering do not require it.
7
+
8
+ ## 0.3.0
9
+
10
+ - Add `ReactEmailRails.parse` to convert semantic HTML into a canonical `@react-email/editor` document using a renderer's extensions.
11
+ - Add `@tiptap/html` and `happy-dom` as optional peer dependencies, required only when calling `parse`; compose-only document rendering does not require them.
12
+ - Bump the render protocol to 3 (the renderer now accepts parse requests). The Ruby gem and npm package must be upgraded together, as before.
13
+
3
14
  ## 0.2.0
4
15
 
5
- - Add `ReactEmailRails.compose` for server-side rendering of `@react-email/editor` documents (Tiptap/ProseMirror JSON) to HTML and text, the server analog of the editor's client-side `composeReactEmail` export.
6
- - Add the `documents` Vite plugin option for discovering document renderers, parallel to `emails`. It is off by default; the editor packages stay out of the email render path and build graph unless it is enabled.
7
- - Report document nodes that render to nothing (a node whose extension is not an email renderer) as non-fatal warnings on the result (`rendered.warnings`) and the `render.react-email-rails` instrumentation payload (`payload[:warnings]`) — so silently dropped content is detectable.
16
+ - Add `ReactEmailRails.compose` for server-side rendering of `@react-email/editor` documents to HTML and text.
17
+ - Add the `documents` Vite plugin option for discovering document renderers, parallel to `emails`.
18
+ - Report document nodes that render to nothing as non-fatal warnings on the result and instrumentation payload.
8
19
  - Add `@react-email/editor` and `@tiptap/core` as optional peer dependencies (only required when rendering documents).
9
20
  - Bump the render protocol to 2 (the renderer now accepts document requests). The Ruby gem and npm package must be upgraded together, as before.
10
- - **Breaking:** `on_render_error` callbacks now receive a uniform `(error, **context)` shape, where `context` carries `kind:` (`"email"`/`"document"`) and either `component:` (emails) or `type:` (documents). Update `->(error, component:) { ... }` callbacks to `->(error, **context) { ... }`.
21
+ - **Breaking:** `on_render_error` callbacks now receive `(error, **context)` with `kind:` plus `component:` for emails or `type:` for documents.
11
22
 
12
23
  ## 0.1.3
13
24
 
data/README.md CHANGED
@@ -44,7 +44,6 @@ React Email Rails automatically renders both HTML and plain-text versions from t
44
44
  - Vite 7 or 8
45
45
  - React 18 or 19
46
46
  - `@react-email/render` 2.x
47
- - For [rendering editor documents](#editor) (optional): `@react-email/editor` 1.5+ and `@tiptap/core` 3.x
48
47
 
49
48
  > We recommend [rails_vite](https://github.com/skryukov/rails_vite/) for Vite with Rails.
50
49
 
@@ -311,6 +310,18 @@ Install the editor packages:
311
310
  npm i @react-email/editor @tiptap/core
312
311
  ```
313
312
 
313
+ To also parse HTML into documents with [`parse`](#parsing-html-or-markdown-into-a-document), add `@tiptap/html` and its server DOM, `happy-dom`:
314
+
315
+ ```sh
316
+ npm i @tiptap/html happy-dom
317
+ ```
318
+
319
+ To parse Markdown as well, add [`marked`](https://marked.js.org) — it converts Markdown to HTML, which then runs through the same parser:
320
+
321
+ ```sh
322
+ npm i marked
323
+ ```
324
+
314
325
  Enable the `documents` option in your Vite config:
315
326
 
316
327
  ```ts
@@ -336,7 +347,6 @@ A document doesn't carry the editor configuration it was authored with, so a hea
336
347
  import { StarterKit } from "@react-email/editor/extensions"
337
348
  import { EmailTheming } from "@react-email/editor/plugins"
338
349
 
339
- // Required: the Tiptap extensions the document was authored with.
340
350
  export function buildExtensions() {
341
351
  return [StarterKit, EmailTheming]
342
352
  }
@@ -362,15 +372,12 @@ export function buildExtensions(context) {
362
372
  return [StarterKit, EmailTheming]
363
373
  }
364
374
 
365
- // Inject a branded header after the persisted theme node, wherever it sits.
366
375
  export function transformDocument(document, context) {
367
376
  const header = {
368
377
  type: "heading",
369
378
  attrs: { level: 1 },
370
379
  content: [{ type: "text", text: context.brandName }],
371
380
  }
372
- // Find globalContent and insert after it, rather than assuming a position, so
373
- // the theme node is preserved.
374
381
  const themeIndex = document.content.findIndex((node) => node.type === "globalContent")
375
382
  const at = themeIndex + 1
376
383
  return {
@@ -397,9 +404,9 @@ broadcast = Broadcast.find(params[:id])
397
404
 
398
405
  rendered = ReactEmailRails.compose(
399
406
  type: "broadcast",
400
- document: broadcast.body, # Tiptap JSON, e.g. a jsonb column
407
+ document: broadcast.body,
401
408
  context: { brand_name: "Acme", preview_text: broadcast.subject },
402
- preview: broadcast.subject, # optional; falls back to getPreview(context)
409
+ preview: broadcast.subject,
403
410
  )
404
411
 
405
412
  rendered.html # => "<!DOCTYPE html>..."
@@ -408,10 +415,56 @@ rendered.text # => "ACME\n\n..."
408
415
 
409
416
  It returns the same `RenderedEmail` (`html`/`text`) as `render`, runs through the same [render modes](#render-modes), and raises `ReactEmailRails::RenderError` on failure. Documents don't go through Action Mailer — broadcasts and the like usually have their own delivery path — so deliver `rendered.html`/`rendered.text` however your app sends mail.
410
417
 
411
- **Keys:** `context` is key-transformed exactly like component props (so `brand_name` arrives as `brandName`, per [`transform_props`](#prop-transformation)). The **`document` is passed through verbatim** its keys (`type`, `attrs`, `content`, `marks`, node names, `globalContent`) are structural and are never transformed.
418
+ **The document is a `Hash`.** You pass and store it as a plain Ruby `Hash` with string keys what a jsonb column hands back, and what [`parse`](#parsing-html-or-markdown-into-a-document) returns. "Tiptap JSON" names the document's *shape* (and the format it's serialized to over the wire to the renderer), not the Ruby type; `compose` accepts any object that responds to `as_json`, but a `Hash` is the norm.
419
+
420
+ **Keys:** the document's keys (`type`, `attrs`, `content`, `marks`, node names, `globalContent`) are structural and **passed through verbatim** — never transformed. Only `context` is key-transformed, camelized exactly like component props (so `brand_name` arrives as `brandName`, per [`transform_props`](#prop-transformation)).
412
421
 
413
422
  `render_options` does not apply to documents; `composeReactEmail` controls its own rendering.
414
423
 
424
+ ### Parsing HTML or Markdown into a Document
425
+
426
+ `ReactEmailRails.parse` converts semantic HTML into the same document `Hash` shape the editor stores, using the selected renderer's extensions. This needs the `@tiptap/html` and `happy-dom` packages (see [Setup](#setup)).
427
+
428
+ ```ruby
429
+ document = ReactEmailRails.parse(
430
+ type: "broadcast",
431
+ html: params[:body_html],
432
+ context: { brand_name: "Acme" },
433
+ )
434
+
435
+ broadcast.update!(body: document)
436
+ ```
437
+
438
+ Later, render the stored document like any other:
439
+
440
+ ```ruby
441
+ rendered = ReactEmailRails.compose(type: "broadcast", document: broadcast.body)
442
+ ```
443
+
444
+ `parse` returns a plain Ruby `Hash` with string keys, normalized through the renderer's schema. It uses the same [render modes](#render-modes) as `compose` and raises `ReactEmailRails::RenderError` on failure. `context` is key-transformed like component props; the HTML is sent verbatim.
445
+
446
+ #### Markdown
447
+
448
+ Pass `markdown:` in place of `html:` to parse Markdown — useful when the content comes from an LLM or another tool that emits Markdown more readily than HTML. It's converted to HTML with [`marked`](https://marked.js.org) and run through the same parse path, so it needs `marked` installed alongside the HTML peers (see [Setup](#setup)).
449
+
450
+ ```ruby
451
+ document = ReactEmailRails.parse(
452
+ type: "broadcast",
453
+ markdown: "# Welcome\n\nThanks for signing up, **Ada**.",
454
+ context: { brand_name: "Acme" },
455
+ )
456
+ ```
457
+
458
+ Pass exactly one of `html:` or `markdown:` — passing both, or neither, raises `ArgumentError`.
459
+
460
+ Markdown is a lower-friction *input*, not a wider one. It expresses less than HTML — headings, paragraphs, emphasis, links, lists, blockquotes, code, images, and rules — so it adds no new node types. Markdown that maps to nodes the renderer's extensions don't define (such as a GFM table without a table extension) is dropped or flattened, the same as the equivalent HTML.
461
+
462
+ What this means in practice:
463
+
464
+ - HTML maps to a node only when an extension defines how to parse it. Unknown elements, inline styles, and classes may be dropped or flattened.
465
+ - Editor-only constructs such as custom email nodes and the persisted `globalContent` theme node do not round-trip from plain HTML or Markdown.
466
+ - If you already have the document `Hash`, pass it to `compose` directly.
467
+
415
468
  ### Debugging Dropped Content
416
469
 
417
470
  The most common integration bug is an extension/document mismatch. A node whose type is **missing from `buildExtensions` entirely** raises `ReactEmailRails::RenderError` (it can't be parsed) — loud and safe. The quiet case is a node that *is* in the schema but whose extension does not render to email (a plain Tiptap node rather than an email one): `composeReactEmail` renders it as nothing, with no error.
@@ -510,7 +563,7 @@ end
510
563
 
511
564
  #### Error Reporting
512
565
 
513
- Use `on_render_error` to report failures before the exception is re-raised. The callback receives the error and a `context` of `kind:` (`"email"` or `"document"`) plus the identifier — `component:` for emails, `type:` for documents. Accept `**context` so one handler covers both render kinds:
566
+ Use `on_render_error` to report failures before the exception is re-raised. The callback receives the error and a `context` of `kind:` (`"email"`, `"document"`, or `"parse"`) plus the identifier — `component:` for emails, `type:` for documents and parse requests. Accept `**context` so one handler covers every render kind:
514
567
 
515
568
  ```ruby
516
569
  ReactEmailRails.configure do |config|
@@ -522,7 +575,7 @@ end
522
575
 
523
576
  #### Instrumentation
524
577
 
525
- Every render emits an [ActiveSupport::Notifications](https://guides.rubyonrails.org/active_support_instrumentation.html) event named `render.react-email-rails`, so you can log render timing or forward it to your APM. The payload carries a `kind` (`"email"` or `"document"`), the `component` name (email) or `type` (document), and, on success, the rendered HTML size in `html_bytes`. Document renders that drop content also include `warnings` (see [Debugging Dropped Content](#debugging-dropped-content)):
578
+ Every render emits an [ActiveSupport::Notifications](https://guides.rubyonrails.org/active_support_instrumentation.html) event named `render.react-email-rails`, so you can log render timing or forward it to your APM. The payload carries a `kind` (`"email"`, `"document"`, or `"parse"`), the `component` name (email) or `type` (document and parse), and, on a successful render, the rendered HTML size in `html_bytes` (omitted for `parse`, which returns a document rather than HTML). Document renders that drop content also include `warnings` (see [Debugging Dropped Content](#debugging-dropped-content)):
526
579
 
527
580
  ```ruby
528
581
  ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
@@ -2,11 +2,14 @@ require("rails/generators/named_base")
2
2
  require("json")
3
3
  require("open3")
4
4
  require("timeout")
5
+ require_relative("vite_config_files")
5
6
 
6
7
  module ReactEmailRails; end
7
8
  module ReactEmailRails::Generators; end
8
9
 
9
10
  class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
11
+ CONFIG_BIN = "node_modules/.bin/react-email-rails-config"
12
+
10
13
  source_root(File.expand_path("templates/email", __dir__))
11
14
 
12
15
  argument(:actions, type: :array, default: [], banner: "method method")
@@ -80,7 +83,7 @@ class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
80
83
  end
81
84
 
82
85
  def component_base_path
83
- File.join(emails_path, "#{file_path}_mailer")
86
+ File.join(emails_path, mailer_file_path)
84
87
  end
85
88
 
86
89
  def emails_path
@@ -131,47 +134,50 @@ class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
131
134
 
132
135
  def vite_config_command
133
136
  [
134
- "node_modules/.bin/react-email-rails-config",
135
- "node_modules/.bin/react-email-rails-config.cmd",
137
+ CONFIG_BIN,
138
+ "#{CONFIG_BIN}.cmd",
136
139
  ].find { |path| File.exist?(File.join(destination_root, path)) }
137
140
  end
138
141
 
142
+ # The reactEmailRails({ ... }) plugin call, up to the option key being scanned for.
143
+ PLUGIN_OPENING = /reactEmailRails\s*\(\s*\{.*?/m
144
+
139
145
  def emails_path_from_vite_config
140
146
  source = vite_config_source
141
147
  return unless source
142
148
 
143
- source[
144
- /reactEmailRails\s*\(\s*\{.*?emails:\s*["']([^"']+)["']/m,
145
- 1,
146
- ] || source[
147
- /reactEmailRails\s*\(\s*\{.*?emails:\s*\{.*?path:\s*["']([^"']+)["']/m,
148
- 1,
149
- ]
149
+ first_capture(
150
+ source,
151
+ /#{PLUGIN_OPENING}emails:\s*["']([^"']+)["']/m,
152
+ /#{PLUGIN_OPENING}emails:\s*\{.*?path:\s*["']([^"']+)["']/m,
153
+ )
150
154
  end
151
155
 
152
156
  def extension_from_vite_config
153
157
  source = vite_config_source
154
158
  return unless source
155
159
 
156
- source[
157
- /reactEmailRails\s*\(\s*\{.*?emails:\s*\{.*?extension:\s*["']([^"']+)["']/m,
158
- 1,
159
- ] || source[
160
- /reactEmailRails\s*\(\s*\{.*?emails:\s*\{.*?extension:\s*\[\s*["']([^"']+)["']/m,
161
- 1,
162
- ]
160
+ first_capture(
161
+ source,
162
+ /#{PLUGIN_OPENING}emails:\s*\{.*?extension:\s*["']([^"']+)["']/m,
163
+ /#{PLUGIN_OPENING}emails:\s*\{.*?extension:\s*\[\s*["']([^"']+)["']/m,
164
+ )
165
+ end
166
+
167
+ # First capture-group match across an ordered list of patterns, or nil.
168
+ def first_capture(source, *patterns)
169
+ patterns.each do |pattern|
170
+ match = source[pattern, 1]
171
+ return match if match
172
+ end
173
+ nil
163
174
  end
164
175
 
165
176
  def vite_config_source
166
177
  @vite_config_source ||= begin
167
- path = [
168
- "vite.config.ts",
169
- "vite.config.mts",
170
- "vite.config.js",
171
- "vite.config.mjs",
172
- "vite.config.cts",
173
- "vite.config.cjs",
174
- ].find { |candidate| File.exist?(File.join(destination_root, candidate)) }
178
+ path = ReactEmailRails::Generators::VITE_CONFIG_FILES.find do |candidate|
179
+ File.exist?(File.join(destination_root, candidate))
180
+ end
175
181
 
176
182
  File.read(File.join(destination_root, path)) if path
177
183
  end
@@ -1,4 +1,5 @@
1
1
  require("json")
2
+ require_relative("vite_config_files")
2
3
 
3
4
  module ReactEmailRails; end
4
5
  module ReactEmailRails::Generators; end
@@ -21,14 +22,7 @@ class ReactEmailRails::Generators::InstallGenerator < Rails::Generators::Base
21
22
  }.freeze
22
23
 
23
24
  SUPPORTED_PACKAGE_MANAGERS = ["bun", "npm", "pnpm", "yarn"].freeze
24
- VITE_CONFIG_FILES = [
25
- "vite.config.ts",
26
- "vite.config.mts",
27
- "vite.config.js",
28
- "vite.config.mjs",
29
- "vite.config.cts",
30
- "vite.config.cjs",
31
- ].freeze
25
+ VITE_CONFIG_FILES = ReactEmailRails::Generators::VITE_CONFIG_FILES
32
26
 
33
27
  VITE_IMPORT = 'import { reactEmailRails } from "react-email-rails"'
34
28
 
@@ -0,0 +1,14 @@
1
+ module ReactEmailRails; end
2
+ module ReactEmailRails::Generators; end
3
+
4
+ module ReactEmailRails::Generators
5
+ # Candidate Vite config filenames in precedence order; shared by both generators.
6
+ VITE_CONFIG_FILES = [
7
+ "vite.config.ts",
8
+ "vite.config.mts",
9
+ "vite.config.js",
10
+ "vite.config.mjs",
11
+ "vite.config.cts",
12
+ "vite.config.cjs",
13
+ ].freeze
14
+ end
@@ -1,6 +1,8 @@
1
1
  class ReactEmailRails::Configuration
2
+ # Must match OUT_DIR/BUNDLE_FILE in vite/src/index.ts (check_version_sync.rb asserts it).
2
3
  BUNDLE_PATH = "tmp/react-email-rails/emails.js"
3
4
  BUILD_BIN = "node_modules/.bin/react-email-rails-build"
5
+ CONFIG_BIN = "node_modules/.bin/react-email-rails-config"
4
6
  DEV_RENDER_BIN = "node_modules/.bin/react-email-rails-dev"
5
7
 
6
8
  DEFAULT_RENDER_TIMEOUT = 10
@@ -33,6 +35,7 @@ class ReactEmailRails::Configuration
33
35
  :transform_props,
34
36
  :on_render_error,
35
37
  )
38
+
36
39
  attr_reader(
37
40
  :render_mode,
38
41
  :render_timeout,
@@ -83,6 +86,8 @@ class ReactEmailRails::Configuration
83
86
  end
84
87
  end
85
88
 
89
+ # A callable render_options is instance_exec'd against `context` (the mailer) when given,
90
+ # so it can use per-mail helpers; otherwise it's called or returned as-is.
86
91
  def resolve_render_options(context = nil)
87
92
  value =
88
93
  if render_options.respond_to?(:call) && context
@@ -93,7 +98,7 @@ class ReactEmailRails::Configuration
93
98
  render_options
94
99
  end
95
100
 
96
- deep_camelize_keys(value.as_json)
101
+ deep_transform_keys(value.as_json, KEY_TRANSFORMS.fetch(:lower_camel))
97
102
  end
98
103
 
99
104
  private
@@ -124,15 +129,4 @@ class ReactEmailRails::Configuration
124
129
  value
125
130
  end
126
131
  end
127
-
128
- def deep_camelize_keys(value)
129
- case value
130
- when Array
131
- value.map { |item| deep_camelize_keys(item) }
132
- when Hash
133
- value.transform_keys { |key| key.to_s.camelize(:lower) }.transform_values { |item| deep_camelize_keys(item) }
134
- else
135
- value
136
- end
137
- end
138
132
  end
@@ -34,8 +34,7 @@ class ReactEmailRails::PropsResolver
34
34
  end
35
35
 
36
36
  def assign_props
37
- # `react: true` infers the component name; instance vars become props only when the
38
- # mailer opts in. Without it, the component renders with no props.
37
+ # `react: true` infers the component; instance vars become props only when the mailer opts in.
39
38
  return {} unless mailer.class.react_email_use_instance_props
40
39
 
41
40
  mailer.instance_variables.each_with_object({}) do |ivar, props|
@@ -1,4 +1,7 @@
1
1
  class ReactEmailRails::RenderModes::Persistent::CommandRunner
2
+ # Eager (not lazy) so concurrent first renders can't each create separate Mutexes.
3
+ @mutex = Mutex.new
4
+
2
5
  class << self
3
6
  def capture(command, input:, timeout:, max_requests: nil)
4
7
  server_for(command).capture(input:, timeout:, max_requests:)
@@ -6,13 +9,13 @@ class ReactEmailRails::RenderModes::Persistent::CommandRunner
6
9
 
7
10
  def healthy?(command, timeout:)
8
11
  result = server_for(command).health_check(timeout:)
9
- result.status.success? && ReactEmailRails::RenderProtocol.compatible_response?(JSON.parse(result.stdout))
12
+ ReactEmailRails::RenderProtocol.healthy_result?(result)
10
13
  rescue StandardError
11
14
  false
12
15
  end
13
16
 
14
17
  def stop_all
15
- @mutex&.synchronize do
18
+ @mutex.synchronize do
16
19
  @servers&.each_value(&:stop)
17
20
  @servers&.clear
18
21
  end
@@ -21,18 +24,14 @@ class ReactEmailRails::RenderModes::Persistent::CommandRunner
21
24
  private
22
25
 
23
26
  def server_for(command)
24
- @mutex ||= Mutex.new
25
-
26
27
  @mutex.synchronize do
27
28
  reset_after_fork
28
29
  @servers[command.map(&:to_s)] ||= ReactEmailRails::RenderModes::Persistent::Server.new(command)
29
30
  end
30
31
  end
31
32
 
32
- # A forked child inherits the parent's Server objects and the open pipes to
33
- # the parent's render processes. Sharing those pipes interleaves requests
34
- # and responses across processes, so drop them (without killing the
35
- # parent-owned process) and let this process spawn its own on demand.
33
+ # A forked child inherits the parent's Servers and their open pipes; sharing those pipes
34
+ # interleaves requests across processes, so drop them without killing the parent's process.
36
35
  def reset_after_fork
37
36
  return if @owner_pid == Process.pid && @servers
38
37
 
@@ -1,6 +1,7 @@
1
1
  class ReactEmailRails::RenderModes::Persistent::Server
2
2
  STDERR_LIMIT = 8 * 1024
3
3
 
4
+ # Minimal Process::Status stand-in; only #success? is ever read.
4
5
  Status = Data.define(:success) do
5
6
  def success? = success
6
7
  end
@@ -15,29 +16,13 @@ class ReactEmailRails::RenderModes::Persistent::Server
15
16
  end
16
17
 
17
18
  def capture(input:, timeout:, max_requests:)
18
- @mutex.synchronize do
19
+ with_retry_on_broken_pipe do
19
20
  capture_once(input:, timeout:).tap { recycle_if_needed(max_requests) }
20
21
  end
21
- rescue Errno::EPIPE, IOError
22
- stop
23
- begin
24
- @mutex.synchronize do
25
- capture_once(input:, timeout:).tap { recycle_if_needed(max_requests) }
26
- end
27
- rescue Errno::EPIPE, IOError
28
- failure("render process exited before responding")
29
- end
30
22
  end
31
23
 
32
24
  def health_check(timeout:)
33
- @mutex.synchronize { health_check_once(timeout:) }
34
- rescue Errno::EPIPE, IOError
35
- stop
36
- begin
37
- @mutex.synchronize { health_check_once(timeout:) }
38
- rescue Errno::EPIPE, IOError
39
- failure("render process exited before responding")
40
- end
25
+ with_retry_on_broken_pipe { health_check_once(timeout:) }
41
26
  end
42
27
 
43
28
  def stop
@@ -49,30 +34,46 @@ class ReactEmailRails::RenderModes::Persistent::Server
49
34
  rescue Errno::ESRCH, Errno::EPERM
50
35
  nil
51
36
  ensure
52
- [@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
53
37
  @stderr_reader&.kill
54
- @stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
55
- @stdout_buffer.clear
38
+ release_io
56
39
  end
57
40
 
58
- # Release this process's copy of an inherited child's pipes without signalling
59
- # the process itself, which is still owned by the parent that started it.
41
+ # Release this process's copy of an inherited child's pipes without signalling the parent-owned process.
60
42
  def abandon
61
- [@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
62
- @stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
63
- @stdout_buffer.clear
43
+ release_io
64
44
  rescue IOError
65
45
  nil
66
46
  end
67
47
 
68
48
  private
69
49
 
50
+ # Shared by stop (which also kills the process) and abandon (which must not).
51
+ def release_io
52
+ [@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
53
+ @stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
54
+ @stdout_buffer.clear
55
+ end
56
+
57
+ # Run under the mutex; on a broken pipe, stop and retry once (the child respawns).
58
+ def with_retry_on_broken_pipe(&block)
59
+ @mutex.synchronize(&block)
60
+ rescue Errno::EPIPE, IOError
61
+ stop
62
+ begin
63
+ @mutex.synchronize(&block)
64
+ rescue Errno::EPIPE, IOError
65
+ failure("render process exited before responding")
66
+ end
67
+ end
68
+
70
69
  attr_reader(:command)
71
70
 
72
71
  def capture_once(input:, timeout:)
73
72
  response = request(input, timeout:)
74
73
  return failure(response["error"].to_s.presence || "render process failed") unless response["ok"]
75
74
 
75
+ # Re-serialize into the same Result.stdout contract the subprocess produces, so
76
+ # Subprocess#run parses and validates every render uniformly.
76
77
  success(JSON.generate(
77
78
  {
78
79
  protocolVersion: response["protocolVersion"],
@@ -81,6 +82,7 @@ class ReactEmailRails::RenderModes::Persistent::Server
81
82
  body[:html] = response["html"] if response.key?("html")
82
83
  body[:text] = response["text"] if response.key?("text")
83
84
  body[:warnings] = response["warnings"] if response.key?("warnings")
85
+ body[:document] = response["document"] if response.key?("document")
84
86
  end,
85
87
  ))
86
88
  rescue JSON::ParserError => e
@@ -8,20 +8,18 @@ class ReactEmailRails::RenderModes::Persistent < ReactEmailRails::RenderModes::S
8
8
  private
9
9
 
10
10
  def capture(input)
11
- CommandRunner.capture(
12
- command,
13
- input:,
14
- timeout: render_timeout,
15
- max_requests: render_process_max_requests,
16
- )
17
- rescue Timeout::Error
18
- raise(render_error("render process timed out after #{render_timeout}s"))
19
- rescue Errno::ENOENT
20
- raise(render_error("render command not found: #{command.inspect}"))
11
+ with_capture_rescues do
12
+ CommandRunner.capture(
13
+ command,
14
+ input:,
15
+ timeout: render_timeout,
16
+ max_requests: render_process_max_requests,
17
+ )
18
+ end
21
19
  end
22
20
 
23
21
  def render_process_max_requests
24
- ReactEmailRails.configuration.send(:render_process_max_requests)
22
+ ReactEmailRails.configuration.render_process_max_requests
25
23
  end
26
24
  end
27
25
 
@@ -50,7 +50,7 @@ class ReactEmailRails::RenderModes::Subprocess::CommandRunner
50
50
 
51
51
  def terminate_process(signal, pid)
52
52
  Process.kill(signal, -pid)
53
- rescue Errno::ESRCH
53
+ rescue Errno::ESRCH, Errno::EPERM
54
54
  nil
55
55
  end
56
56
  end
@@ -2,17 +2,18 @@ class ReactEmailRails::RenderModes::Subprocess
2
2
  class << self
3
3
  def healthy?(command:, timeout:)
4
4
  result = CommandRunner.capture([*command, "--health"], timeout:)
5
- result.status.success? && ReactEmailRails::RenderProtocol.compatible_response?(JSON.parse(result.stdout))
5
+ ReactEmailRails::RenderProtocol.healthy_result?(result)
6
6
  rescue StandardError
7
7
  false
8
8
  end
9
9
  end
10
10
 
11
- # Payload-agnostic transport: the caller builds and serializes the payload.
12
- # `label` identifies the render in error messages (component name or document type).
13
- def initialize(payload:, label:)
11
+ # `label` names the render in error messages. `response` reads the reply as `:email`
12
+ # (RenderedEmail, for render/compose) or `:document` (parsed document, for parse).
13
+ def initialize(payload:, label:, response: :email)
14
14
  @payload = payload
15
15
  @label = label
16
+ @response = response
16
17
  end
17
18
 
18
19
  def render
@@ -21,15 +22,15 @@ class ReactEmailRails::RenderModes::Subprocess
21
22
 
22
23
  private
23
24
 
24
- attr_reader(:payload, :label)
25
+ attr_reader(:payload, :label, :response)
25
26
 
26
27
  def run
27
28
  result = capture(payload_json)
28
29
  raise(render_error(error_message(result.stderr, result.status))) unless result.status.success?
29
30
 
30
31
  body = JSON.parse(result.stdout)
31
- validate_response!(body)
32
- ReactEmailRails::RenderedEmail.new(html: body.fetch("html"), text: body["text"].to_s, warnings: warnings_from(body))
32
+ validate_metadata!(body)
33
+ build_result(body)
33
34
  rescue JSON::ParserError => e
34
35
  raise(render_error("render process returned invalid JSON: #{e.message}"))
35
36
  rescue KeyError => e
@@ -37,8 +38,15 @@ class ReactEmailRails::RenderModes::Subprocess
37
38
  end
38
39
 
39
40
  def capture(input)
40
- validate_command!
41
- CommandRunner.capture(command, input:, timeout: render_timeout)
41
+ with_capture_rescues do
42
+ validate_command!
43
+ CommandRunner.capture(command, input:, timeout: render_timeout)
44
+ end
45
+ end
46
+
47
+ # Shared by Persistent#capture so the transport-error messages live in one place.
48
+ def with_capture_rescues
49
+ yield
42
50
  rescue Timeout::Error
43
51
  raise(render_error("render process timed out after #{render_timeout}s"))
44
52
  rescue Errno::ENOENT
@@ -75,12 +83,31 @@ class ReactEmailRails::RenderModes::Subprocess
75
83
  raise(render_error("email bundle not found at #{bundle_path.inspect}; run react-email-rails-build before rendering React emails"))
76
84
  end
77
85
 
78
- def validate_response!(body)
79
- raise(render_error(ReactEmailRails::RenderProtocol.mismatch_message(body))) unless ReactEmailRails::RenderProtocol.compatible_metadata?(body)
86
+ def validate_metadata!(body)
87
+ return if ReactEmailRails::RenderProtocol.compatible_metadata?(body)
88
+
89
+ raise(render_error(ReactEmailRails::RenderProtocol.mismatch_message(body)))
90
+ end
91
+
92
+ def build_result(body)
93
+ response == :document ? build_document(body) : build_rendered_email(body)
94
+ end
80
95
 
96
+ def build_rendered_email(body)
81
97
  raise(KeyError.new(key: "html")) unless body.key?("html")
82
98
  raise(render_error("render process returned an invalid response: html must be a string")) unless body["html"].is_a?(String)
83
99
  raise(render_error("render process returned an invalid response: text must be a string")) if body.key?("text") && !body["text"].is_a?(String)
100
+
101
+ ReactEmailRails::RenderedEmail.new(html: body.fetch("html"), text: body["text"].to_s, warnings: warnings_from(body))
102
+ end
103
+
104
+ def build_document(body)
105
+ raise(KeyError.new(key: "document")) unless body.key?("document")
106
+
107
+ document = body.fetch("document")
108
+ raise(render_error("parse process returned an invalid response: document must be an object")) unless document.is_a?(Hash)
109
+
110
+ document
84
111
  end
85
112
 
86
113
  def warnings_from(body)
@@ -1,9 +1,16 @@
1
+ require("json")
2
+
1
3
  module ReactEmailRails
2
- RENDER_PROTOCOL_VERSION = 2
4
+ RENDER_PROTOCOL_VERSION = 3
3
5
 
4
6
  module RenderProtocol
5
7
  extend(self)
6
8
 
9
+ # Callers keep their own rescue to also cover failures obtaining the result.
10
+ def healthy_result?(result)
11
+ result.status.success? && compatible_response?(JSON.parse(result.stdout))
12
+ end
13
+
7
14
  def compatible_response?(body)
8
15
  body["ok"] == true && compatible_metadata?(body)
9
16
  end
@@ -1,6 +1,5 @@
1
1
  module ReactEmailRails
2
- # `warnings` carries non-fatal renderer warnings (e.g. document nodes dropped
3
- # because no extension rendered them); empty for component renders.
2
+ # `warnings` are non-fatal (document nodes nothing rendered); empty for component renders.
4
3
  RenderedEmail = Data.define(:html, :text, :warnings) do
5
4
  def initialize(html:, text:, warnings: [])
6
5
  super
@@ -1,3 +1,3 @@
1
1
  module ReactEmailRails
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -40,16 +40,10 @@ module ReactEmailRails
40
40
  payload = { component:, props: serialized_props(props) }
41
41
  payload[:renderOptions] = render_options if render_options.present?
42
42
 
43
- instrument(kind: "email", component:) do
44
- configuration.resolved_render_mode.new(payload:, label: component).render
45
- end
46
- rescue ReactEmailRails::RenderError => e
47
- configuration.on_render_error&.call(e, kind: "email", component:)
48
- raise
43
+ perform(payload:, label: component, kind: "email", component:)
49
44
  end
50
45
 
51
- # Render an @react-email/editor document (Tiptap JSON) to HTML+text. The document
52
- # is sent verbatim (its keys are structural); only context is key-transformed, like props.
46
+ # The document is sent verbatim (keys are structural); only context is key-transformed, like props.
53
47
  def compose(type:, document:, context: {}, preview: nil)
54
48
  payload = {
55
49
  kind: "document",
@@ -59,12 +53,19 @@ module ReactEmailRails
59
53
  preview:,
60
54
  }
61
55
 
62
- instrument(kind: "document", type:) do
63
- configuration.resolved_render_mode.new(payload:, label: type).render
64
- end
65
- rescue ReactEmailRails::RenderError => e
66
- configuration.on_render_error&.call(e, kind: "document", type:)
67
- raise
56
+ perform(payload:, label: type, kind: "document", type:)
57
+ end
58
+
59
+ # Parse semantic HTML or Markdown into an editor document Hash using the renderer's
60
+ # extensions. Pass exactly one of `html:` or `markdown:`.
61
+ def parse(type:, html: nil, markdown: nil, context: {})
62
+ payload = {
63
+ kind: "parse",
64
+ type:,
65
+ context: serialized_props(context),
66
+ }.merge(parse_source(html:, markdown:))
67
+
68
+ perform(payload:, label: type, response: :document, kind: "parse", type:)
68
69
  end
69
70
 
70
71
  def healthy?
@@ -78,15 +79,38 @@ module ReactEmailRails
78
79
 
79
80
  private
80
81
 
82
+ def perform(payload:, label:, response: :email, **metadata)
83
+ instrument(**metadata) do
84
+ configuration.resolved_render_mode.new(payload:, label:, response:).render
85
+ end
86
+ rescue ReactEmailRails::RenderError => e
87
+ configuration.on_render_error&.call(e, **metadata)
88
+ raise
89
+ end
90
+
91
+ # Markdown is converted renderer-side, not here; both inputs are sent as-is.
92
+ def parse_source(html:, markdown:)
93
+ if !html.nil? && !markdown.nil?
94
+ raise(ArgumentError, "ReactEmailRails.parse accepts only one of html: or markdown:")
95
+ end
96
+
97
+ return { html: html.to_s } unless html.nil?
98
+ return { markdown: markdown.to_s } unless markdown.nil?
99
+
100
+ raise(ArgumentError, "ReactEmailRails.parse requires html: or markdown:")
101
+ end
102
+
81
103
  def serialized_props(value)
82
104
  configuration.send(:serialize_props, value)
83
105
  end
84
106
 
85
107
  def instrument(**metadata)
86
108
  ActiveSupport::Notifications.instrument("render.react-email-rails", **metadata) do |payload|
87
- yield.tap do |rendered|
88
- payload[:html_bytes] = rendered.html.bytesize
89
- payload[:warnings] = rendered.warnings if rendered.warnings.present?
109
+ yield.tap do |result|
110
+ next unless result.respond_to?(:html)
111
+
112
+ payload[:html_bytes] = result.html.bytesize
113
+ payload[:warnings] = result.warnings if result.warnings.present?
90
114
  end
91
115
  end
92
116
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: react-email-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supertape
@@ -148,6 +148,7 @@ files:
148
148
  - lib/generators/react_email_rails/templates/email/mailer_test.rb.tt
149
149
  - lib/generators/react_email_rails/templates/initializer.rb
150
150
  - lib/generators/react_email_rails/templates/vite.config.ts
151
+ - lib/generators/react_email_rails/vite_config_files.rb
151
152
  - lib/react-email-rails.rb
152
153
  - lib/react_email_rails.rb
153
154
  - lib/react_email_rails/action_mailer.rb