react-email-rails 0.3.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: 52da00b9618d5b217b66af2598edf2a25fa9d0850447bfc95d8637c778837583
4
- data.tar.gz: 84bcdbd09672b293b85d65bbde9779c0d0d2c2c282dd83e72cc2412d38150c6b
3
+ metadata.gz: 1ada860a1bfdc970fd871cb2f0d4b89642e42b8641b8d11006ca66e9bf6f452a
4
+ data.tar.gz: 742b838451251d7b97093077ff9909e3d60441e705a841fed522d1d64df83787
5
5
  SHA512:
6
- metadata.gz: b9ce2a8643e90776bee1f37c9758fd9d853465d37b73184e0ebdeb20a87e33d9d686354b1a8256cb3bb4887221d25b7c14c9b6733d905c04e86f3fa2d1e06cc7
7
- data.tar.gz: 1257c29d6639ed5a1559d23d64316bea2c6ccf075b365e816dc0bfd41cb996003e466dbabe97bc50aa9b736e2a45e81e083cd908791f91d8d94f13b239e65f67
6
+ metadata.gz: 2d7490d1c6bccdd3d7d7eeb5fa0f0a1d6a9888e6090bf560b2d63feab8db4edec448eb7ba6ddbc34134ed9cf1cd34e4a2cb229bf41794c44faefbafe0f98adc0
7
+ data.tar.gz: c2a55b22219041afe9cc25f00fa5b2ef222f4d45656027c4e971b2666d90a68f02aae4894fc30f3771b3064e41b5c880febead6d79d4cecadb0cb5bd1736e3de
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## 0.3.0
4
9
 
5
10
  - Add `ReactEmailRails.parse` to convert semantic HTML into a canonical `@react-email/editor` document using a renderer's extensions.
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,12 +310,18 @@ Install the editor packages:
311
310
  npm i @react-email/editor @tiptap/core
312
311
  ```
313
312
 
314
- To also parse HTML into documents with [`parse`](#parsing-html-into-a-document), add `@tiptap/html` and its server DOM, `happy-dom`:
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`:
315
314
 
316
315
  ```sh
317
316
  npm i @tiptap/html happy-dom
318
317
  ```
319
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
+
320
325
  Enable the `documents` option in your Vite config:
321
326
 
322
327
  ```ts
@@ -342,7 +347,6 @@ A document doesn't carry the editor configuration it was authored with, so a hea
342
347
  import { StarterKit } from "@react-email/editor/extensions"
343
348
  import { EmailTheming } from "@react-email/editor/plugins"
344
349
 
345
- // Required: the Tiptap extensions the document was authored with.
346
350
  export function buildExtensions() {
347
351
  return [StarterKit, EmailTheming]
348
352
  }
@@ -368,15 +372,12 @@ export function buildExtensions(context) {
368
372
  return [StarterKit, EmailTheming]
369
373
  }
370
374
 
371
- // Inject a branded header after the persisted theme node, wherever it sits.
372
375
  export function transformDocument(document, context) {
373
376
  const header = {
374
377
  type: "heading",
375
378
  attrs: { level: 1 },
376
379
  content: [{ type: "text", text: context.brandName }],
377
380
  }
378
- // Find globalContent and insert after it, rather than assuming a position, so
379
- // the theme node is preserved.
380
381
  const themeIndex = document.content.findIndex((node) => node.type === "globalContent")
381
382
  const at = themeIndex + 1
382
383
  return {
@@ -403,9 +404,9 @@ broadcast = Broadcast.find(params[:id])
403
404
 
404
405
  rendered = ReactEmailRails.compose(
405
406
  type: "broadcast",
406
- document: broadcast.body, # a Hash in Tiptap's shape, e.g. from a jsonb column
407
+ document: broadcast.body,
407
408
  context: { brand_name: "Acme", preview_text: broadcast.subject },
408
- preview: broadcast.subject, # optional; falls back to getPreview(context)
409
+ preview: broadcast.subject,
409
410
  )
410
411
 
411
412
  rendered.html # => "<!DOCTYPE html>..."
@@ -414,24 +415,24 @@ rendered.text # => "ACME\n\n..."
414
415
 
415
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.
416
417
 
417
- **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-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.
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.
418
419
 
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)).
420
421
 
421
422
  `render_options` does not apply to documents; `composeReactEmail` controls its own rendering.
422
423
 
423
- ### Parsing HTML into a Document
424
+ ### Parsing HTML or Markdown into a Document
424
425
 
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)).
426
427
 
427
428
  ```ruby
428
429
  document = ReactEmailRails.parse(
429
- type: "broadcast", # the same renderer type compose uses
430
- html: params[:body_html], # the HTML the caller sent
431
- context: { brand_name: "Acme" }, # optional; reaches buildExtensions, like compose
430
+ type: "broadcast",
431
+ html: params[:body_html],
432
+ context: { brand_name: "Acme" },
432
433
  )
433
434
 
434
- broadcast.update!(body: document) # persist the Hash (e.g. to a jsonb column)
435
+ broadcast.update!(body: document)
435
436
  ```
436
437
 
437
438
  Later, render the stored document like any other:
@@ -442,10 +443,26 @@ rendered = ReactEmailRails.compose(type: "broadcast", document: broadcast.body)
442
443
 
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.
444
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
+
445
462
  What this means in practice:
446
463
 
447
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.
448
- - Editor-only constructs such as custom email nodes and the persisted `globalContent` theme node do not round-trip from plain HTML.
465
+ - Editor-only constructs such as custom email nodes and the persisted `globalContent` theme node do not round-trip from plain HTML or Markdown.
449
466
  - If you already have the document `Hash`, pass it to `compose` directly.
450
467
 
451
468
  ### Debugging Dropped Content
@@ -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"],
@@ -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,16 +2,14 @@ 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
- # `response` selects how the renderer's reply is interpreted: `:email` builds a
14
- # RenderedEmail (render/compose), `:document` returns the parsed document (parse).
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).
15
13
  def initialize(payload:, label:, response: :email)
16
14
  @payload = payload
17
15
  @label = label
@@ -40,8 +38,15 @@ class ReactEmailRails::RenderModes::Subprocess
40
38
  end
41
39
 
42
40
  def capture(input)
43
- validate_command!
44
- 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
45
50
  rescue Timeout::Error
46
51
  raise(render_error("render process timed out after #{render_timeout}s"))
47
52
  rescue Errno::ENOENT
@@ -1,9 +1,16 @@
1
+ require("json")
2
+
1
3
  module ReactEmailRails
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.3.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,29 +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:)
68
57
  end
69
58
 
70
- # Parse HTML into an editor document Hash using the renderer's extensions.
71
- def parse(type:, html:, context: {})
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: {})
72
62
  payload = {
73
63
  kind: "parse",
74
64
  type:,
75
- html: html.to_s,
76
65
  context: serialized_props(context),
77
- }
66
+ }.merge(parse_source(html:, markdown:))
78
67
 
79
- instrument(kind: "parse", type:) do
80
- configuration.resolved_render_mode.new(payload:, label: type, response: :document).render
81
- end
82
- rescue ReactEmailRails::RenderError => e
83
- configuration.on_render_error&.call(e, kind: "parse", type:)
84
- raise
68
+ perform(payload:, label: type, response: :document, kind: "parse", type:)
85
69
  end
86
70
 
87
71
  def healthy?
@@ -95,6 +79,27 @@ module ReactEmailRails
95
79
 
96
80
  private
97
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
+
98
103
  def serialized_props(value)
99
104
  configuration.send(:serialize_props, value)
100
105
  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.3.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