react-email-rails 0.12.0 → 0.12.2

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: e2dc04e0c361e5517d66cf3a60a274b090bc5978b82ee4a77c07758ac8ca82e7
4
- data.tar.gz: 027c7e6c6e4510621b6738c271f273a1a89a248e6de59772bc0af6fec7eebe4d
3
+ metadata.gz: fd436260ad38b370f59dbd8cfad0b09697634624b7c56d0917eee90dc583dc07
4
+ data.tar.gz: 9e5f81391d9864d46903129e4f4fd2971bca156a83c933c79f8d5e520331b0d8
5
5
  SHA512:
6
- metadata.gz: b044fc510c92a914031d4932586b12f2a16c306fee35f4cb4088f22093910456b3a8e3fe4a1909d1e8a67ba10a6235462f4531858a6f575e3516ef8bea20e260
7
- data.tar.gz: d30686390cf1c1d435a24ec640d3a5873cf7137e211c67e6e316a660c3ffa67ae6c78787b90075676ddab90ded4282b2db6730195120ff954c919fd58a3610f3
6
+ metadata.gz: a73846be5159e9787b96ea08813051293b75ae697d35c40a25b42cfad9fef66f5c4df2d36369e2c0ca1824b2abfe178a64d8fd39cef4a293d83355b0f5074a3e
7
+ data.tar.gz: af1228467e11038502e528619e3ca8de5d2b635b97e949fb617aeeb0ff7b1a9ae34ed49fc6024911244ca3ba210b45e90ee15087be0ff17d03694656c3cb8459
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.2
4
+
5
+ - The development renderer keeps its Vite cache in `node_modules/.vite/react-email-rails` and skips dependency discovery. It previously reset the app dev server's `node_modules/.vite/deps` on start, so lazily imported dependencies 504ed until that server restarted.
6
+ - Add `config.js_runtime` (default `"node"`) to choose the executable that runs the development renderer, the production bundle, the build task, and the generator's Vite config probe. The gem now runs the package's `bin/*.mjs` scripts directly instead of relying on `node_modules/.bin` shims and their shebangs.
7
+
8
+ ## 0.12.1
9
+
10
+ - Export `CamelMailer` and `CamelMessage` TypeScript types, plus a `Register` module augmentation (`propKeys: "camel"`) so apps that camelize props can type the injected `mailer` / `message` shapes without a local mapped type. Default `Mailer` / `Message` stay snake_case.
11
+ - Timeouts that already received a partial response line no longer suggest a gem/npm version mismatch. The renderer drains stdout before deciding the process was silent.
12
+
3
13
  ## 0.12.0
4
14
 
5
15
  - **Breaking:** Remove `config.render_mode`. There is one renderer: a long-lived Node child per Ruby process, speaking newline-delimited JSON. Delete `config.render_mode = :persistent` from initializers. Set `config.render_process_max_requests = 1` to recycle the child after every email.
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # react-email-rails
4
4
 
5
- Build and send Action Mailer emails with [React Email](https://react.email), TypeScript, and Vite.
5
+ Build and send Action Mailer emails with [React Email](https://react.email), Tailwind, TypeScript, and Vite.
6
6
 
7
7
  react-email-rails lets Rails render React Email components into HTML and plain text, then deliver them through the Action Mailer stack you already know: previews, headers, callbacks, queues, instrumentation, and delivery all keep working normally.
8
8
 
@@ -334,6 +334,8 @@ type WelcomeProps = {
334
334
  }
335
335
  ```
336
336
 
337
+ The table shows the default snake_case keys. Keys follow `config.prop_transformer` just like your own props, so a camelize transformer yields `mailerName` / `replyTo` at runtime. Augment `Register` once so `Mailer` / `Message` match, or alias `CamelMailer` / `CamelMessage` — see [Prop Transformation](#prop-transformation).
338
+
337
339
  | Prop | Example |
338
340
  |------|---------|
339
341
  | `mailer.mailer_name` | `"account_mailer"` |
@@ -343,9 +345,7 @@ type WelcomeProps = {
343
345
  | `message.cc`, `message.bcc` | `["…"]` or `null` |
344
346
  | `message.from`, `message.reply_to` | `["app@example.com"]` |
345
347
 
346
- Context is merged before prop serialization, so keys follow `config.prop_transformer` just like your own props. The exported TypeScript types describe the default (untransformed) shape.
347
-
348
- Per-mail and shared props win on conflict, so a prop named `mailer` or `message` overrides the injected context. When props come from a serializer, the context is merged in as long as `as_json` returns a hash; collections, arrays, and other non-object values pass through unchanged so their top-level shape is preserved.
348
+ Context is merged before prop serialization. Per-mail and shared props win on conflict, so a prop named `mailer` or `message` overrides the injected context. When props come from a serializer, the context is merged in as long as `as_json` returns a hash; collections, arrays, and other non-object values pass through unchanged so their top-level shape is preserved.
349
349
 
350
350
  ### Prop Serialization
351
351
 
@@ -427,6 +427,7 @@ end
427
427
  | `component_path_resolver` | `->(mailer:, action:) { "#{mailer}/#{action}" }` |
428
428
  | `prop_transformer` | `->(props:) { props }` |
429
429
  | `render_options` | `{}` |
430
+ | `js_runtime` | `"node"` |
430
431
  | `render_timeout` | `30` seconds in development, `10` seconds otherwise |
431
432
  | `render_process_max_requests` | `1_000` |
432
433
  | `on_render_error` | `nil` |
@@ -435,9 +436,7 @@ end
435
436
 
436
437
  ### Prop Transformation
437
438
 
438
- Use `prop_transformer` to change props after `as_json` and before they reach React. The default is a no-op, matching [inertia-rails](https://inertia-rails.dev/guide/configuration#prop_transformer).
439
-
440
- To work with `snake_case` in Ruby and `camelCase` in components:
439
+ Use `prop_transformer` to change props after `as_json` and before they reach React. To work with `snake_case` in Ruby and `camelCase` in components:
441
440
 
442
441
  ```ruby
443
442
  ReactEmailRails.configure do |config|
@@ -447,6 +446,29 @@ ReactEmailRails.configure do |config|
447
446
  end
448
447
  ```
449
448
 
449
+ The transformer is arbitrary, so TypeScript cannot infer it. The default `Mailer` and `Message` types stay snake_case (`mailer_name`, `reply_to`). App-authored props (`expiresMinutes`, and so on) stay hand-typed in the app.
450
+
451
+ If you camelize in Ruby, augment `Register` once so `import type { Mailer, Message }` matches runtime — the same "configure once" idea as the initializer:
452
+
453
+ ```ts
454
+ // e.g. react-email-rails.d.ts
455
+ import "react-email-rails"
456
+
457
+ declare module "react-email-rails" {
458
+ interface Register {
459
+ propKeys: "camel"
460
+ }
461
+ }
462
+ ```
463
+
464
+ The `import "react-email-rails"` (or an `export {}`) makes the file a module so TypeScript *augments* the package. Without it, a script-style `.d.ts` replaces the module and you lose the rest of the package's types.
465
+
466
+ If you do not want a `.d.ts`, alias the camel types at the import. `CamelMailer` and `CamelMessage` are always camel, regardless of `Register`:
467
+
468
+ ```ts
469
+ import type { CamelMailer as Mailer, CamelMessage as Message } from "react-email-rails"
470
+ ```
471
+
450
472
  The transformer receives one hash at a time: the root props object, or each object in a top-level collection. Nested hashes inside a single object are left to the transformer — `deep_transform_keys` walks those.
451
473
 
452
474
  ### Render Process
@@ -461,6 +483,14 @@ ReactEmailRails.configure do |config|
461
483
  end
462
484
  ```
463
485
 
486
+ `js_runtime` names the executable that runs the renderer, the build task, and the generator's Vite config probe. It defaults to `node` from `PATH`. Point it at an absolute path when Node lives behind a version manager the app process cannot see, or at another runtime such as `bun`. Only Node is tested in CI:
487
+
488
+ ```ruby
489
+ ReactEmailRails.configure do |config|
490
+ config.js_runtime = "/usr/local/bin/node"
491
+ end
492
+ ```
493
+
464
494
  ### Render Options
465
495
 
466
496
  `render_options` is passed to [@react-email/render](https://react.email/docs/utilities/render). HTML is rendered once; plain text is derived from that markup with `toPlainText` (or `unstableToPlainText` when `unstable_text_conversion` is set). `html.pretty` is applied only to the HTML part, so it does not change the text body. `text.html_to_text_options` is forwarded to `toPlainText`. Option keys are camelized before they cross into JavaScript, so `html_to_text_options` becomes `htmlToTextOptions`.
@@ -8,7 +8,7 @@ module ReactEmailRails; end
8
8
  module ReactEmailRails::Generators; end
9
9
 
10
10
  class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
11
- CONFIG_BIN = "node_modules/.bin/react-email-rails-config"
11
+ CONFIG_SCRIPT = "node_modules/react-email-rails/bin/config.mjs"
12
12
 
13
13
  source_root(File.expand_path("templates/email", __dir__))
14
14
 
@@ -120,7 +120,7 @@ class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
120
120
  command = vite_config_command
121
121
  if command
122
122
  stdout, _stderr, status = Timeout.timeout(10) do
123
- Open3.capture3(command, chdir: destination_root)
123
+ Open3.capture3(*command, chdir: destination_root)
124
124
  end
125
125
 
126
126
  status.success? ? JSON.parse(stdout) : {}
@@ -133,10 +133,9 @@ class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
133
133
  end
134
134
 
135
135
  def vite_config_command
136
- [
137
- CONFIG_BIN,
138
- "#{CONFIG_BIN}.cmd",
139
- ].find { |path| File.exist?(File.join(destination_root, path)) }
136
+ return unless File.exist?(File.join(destination_root, CONFIG_SCRIPT))
137
+
138
+ [ReactEmailRails.configuration.js_runtime, CONFIG_SCRIPT]
140
139
  end
141
140
 
142
141
  PLUGIN_OPENING = /reactEmailRails\s*\(\s*\{.*?/m
@@ -1,9 +1,10 @@
1
1
  class ReactEmailRails::Configuration
2
2
  BUNDLE_PATH = "tmp/react-email-rails/emails.js"
3
- BUILD_BIN = "node_modules/.bin/react-email-rails-build"
4
- CONFIG_BIN = "node_modules/.bin/react-email-rails-config"
5
- DEV_RENDER_BIN = "node_modules/.bin/react-email-rails-dev"
3
+ BUILD_SCRIPT = "node_modules/react-email-rails/bin/build.mjs"
4
+ CONFIG_SCRIPT = "node_modules/react-email-rails/bin/config.mjs"
5
+ DEV_RENDER_SCRIPT = "node_modules/react-email-rails/bin/dev.mjs"
6
6
 
7
+ DEFAULT_JS_RUNTIME = "node"
7
8
  DEFAULT_RENDER_TIMEOUT = 10
8
9
  DEVELOPMENT_RENDER_TIMEOUT = 30
9
10
  DEFAULT_RENDER_PROCESS_MAX_REQUESTS = 1_000
@@ -11,14 +12,6 @@ class ReactEmailRails::Configuration
11
12
 
12
13
  DEFAULT_PROP_TRANSFORMER = ->(props:) { props }
13
14
 
14
- DEFAULT_RENDER_COMMAND = lambda do
15
- if Rails.env.development?
16
- [Rails.root.join(DEV_RENDER_BIN).to_s]
17
- else
18
- ["node", Rails.root.join(BUNDLE_PATH).to_s]
19
- end
20
- end
21
-
22
15
  attr_accessor(
23
16
  :component_path_resolver,
24
17
  :render_options,
@@ -28,6 +21,7 @@ class ReactEmailRails::Configuration
28
21
  )
29
22
 
30
23
  attr_reader(
24
+ :js_runtime,
31
25
  :prop_transformer,
32
26
  :render_timeout,
33
27
  :render_process_max_requests,
@@ -38,6 +32,7 @@ class ReactEmailRails::Configuration
38
32
  new.tap do |config|
39
33
  config.component_path_resolver = ->(mailer:, action:) { "#{mailer}/#{action}" }
40
34
  config.render_options = {}
35
+ config.js_runtime = DEFAULT_JS_RUNTIME
41
36
  config.render_timeout = Rails.env.development? ? DEVELOPMENT_RENDER_TIMEOUT : DEFAULT_RENDER_TIMEOUT
42
37
  config.render_process_max_requests = DEFAULT_RENDER_PROCESS_MAX_REQUESTS
43
38
  config.prop_transformer = DEFAULT_PROP_TRANSFORMER
@@ -62,6 +57,12 @@ class ReactEmailRails::Configuration
62
57
  @prop_transformer = value
63
58
  end
64
59
 
60
+ def js_runtime=(value)
61
+ raise(ArgumentError, "react-email-rails js_runtime must be a non-empty string") unless value.is_a?(String) && value.present?
62
+
63
+ @js_runtime = value
64
+ end
65
+
65
66
  def render_timeout=(value)
66
67
  raise(ArgumentError, "react-email-rails render_timeout must be positive") unless value.is_a?(Numeric) && value.positive?
67
68
 
@@ -92,7 +93,8 @@ class ReactEmailRails::Configuration
92
93
  private
93
94
 
94
95
  def resolved_render_command
95
- DEFAULT_RENDER_COMMAND.call
96
+ script = Rails.env.development? ? DEV_RENDER_SCRIPT : BUNDLE_PATH
97
+ [js_runtime, Rails.root.join(script).to_s]
96
98
  end
97
99
 
98
100
  def serialize_props(props)
@@ -112,9 +112,16 @@ class ReactEmailRails::Renderer::Child
112
112
 
113
113
  remaining = deadline - monotonic_time
114
114
  if remaining <= 0 || IO.select([@stdout], nil, nil, remaining).nil?
115
- silent = line.empty? && @stdout_buffer.empty?
115
+ drain_stdout_nonblock
116
+ if (buffered_line = consume_buffered_response_line)
117
+ line << buffered_line
118
+ return line
119
+ end
120
+
121
+ line << @stdout_buffer
122
+ @stdout_buffer.clear
116
123
  stop
117
- raise(Timeout::Error, unmatched_package_message) if silent
124
+ raise(Timeout::Error, unmatched_package_message) if line.empty?
118
125
 
119
126
  raise(Timeout::Error)
120
127
  end
@@ -134,6 +141,12 @@ class ReactEmailRails::Renderer::Child
134
141
  "no response received; gem and npm package react-email-rails must both be #{ReactEmailRails::VERSION}"
135
142
  end
136
143
 
144
+ def drain_stdout_nonblock
145
+ loop { @stdout_buffer << @stdout.read_nonblock(16 * 1024) }
146
+ rescue IO::WaitReadable, IOError, Errno::EAGAIN, Errno::EINTR
147
+ nil
148
+ end
149
+
137
150
  def consume_buffered_response_line
138
151
  separator = @stdout_buffer.index("\n")
139
152
  return unless separator
@@ -106,17 +106,16 @@ class ReactEmailRails::Renderer
106
106
  end
107
107
 
108
108
  def validate_command!
109
- command_path = command.first.to_s
110
- bundle_path = command[1].to_s
109
+ script = command[1].to_s
110
+ return if File.file?(script)
111
111
 
112
- if command_path.end_with?(ReactEmailRails::Configuration::DEV_RENDER_BIN) && !File.exist?(command_path)
113
- raise(render_error("development renderer not found at #{command_path.inspect}; install JavaScript dependencies with npm, pnpm, yarn, or bun"))
112
+ if script.end_with?(ReactEmailRails::Configuration::DEV_RENDER_SCRIPT)
113
+ raise(render_error("development renderer not found at #{script.inspect}; install JavaScript dependencies with npm, pnpm, yarn, or bun"))
114
114
  end
115
115
 
116
- return unless command_path == "node" && bundle_path.end_with?(ReactEmailRails::Configuration::BUNDLE_PATH)
117
- return if File.file?(bundle_path)
116
+ return unless script.end_with?(ReactEmailRails::Configuration::BUNDLE_PATH)
118
117
 
119
- raise(render_error("email bundle not found at #{bundle_path.inspect}; run react-email-rails-build before rendering React emails"))
118
+ raise(render_error("email bundle not found at #{script.inspect}; run react-email-rails-build before rendering React emails"))
120
119
  end
121
120
 
122
121
  def validate_metadata!(body)
@@ -4,9 +4,9 @@ module ReactEmailRails::Tasks
4
4
  class << self
5
5
  def build
6
6
  command = build_command
7
- raise("react-email-rails build command not found at #{command.inspect}; run JavaScript package install first") unless File.exist?(command)
7
+ raise("react-email-rails build command not found at #{command.last.inspect}; run JavaScript package install first") unless File.exist?(command.last)
8
8
 
9
- system(command, exception: true, chdir: Rails.root.to_s)
9
+ system(*command, exception: true, chdir: Rails.root.to_s)
10
10
  raise("react-email-rails build completed, but the email bundle was not found at #{bundle_path.inspect}") unless File.file?(bundle_path)
11
11
  end
12
12
 
@@ -24,12 +24,10 @@ module ReactEmailRails::Tasks
24
24
  private
25
25
 
26
26
  def build_command
27
- candidates = [
28
- ReactEmailRails::Configuration::BUILD_BIN,
29
- "#{ReactEmailRails::Configuration::BUILD_BIN}.cmd",
27
+ [
28
+ ReactEmailRails.configuration.js_runtime,
29
+ Rails.root.join(ReactEmailRails::Configuration::BUILD_SCRIPT).to_s,
30
30
  ]
31
- candidates.map { |path| Rails.root.join(path).to_s }.find { |path| File.exist?(path) } ||
32
- Rails.root.join(ReactEmailRails::Configuration::BUILD_BIN).to_s
33
31
  end
34
32
 
35
33
  def bundle_path
@@ -1,3 +1,3 @@
1
1
  module ReactEmailRails
2
- VERSION = "0.12.0"
2
+ VERSION = "0.12.2"
3
3
  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.12.0
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supertape