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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +32 -15
- data/lib/generators/react_email_rails/email_generator.rb +31 -25
- data/lib/generators/react_email_rails/install_generator.rb +2 -8
- data/lib/generators/react_email_rails/vite_config_files.rb +14 -0
- data/lib/react_email_rails/configuration.rb +6 -12
- data/lib/react_email_rails/props_resolver.rb +1 -2
- data/lib/react_email_rails/render_modes/persistent/command_runner.rb +7 -8
- data/lib/react_email_rails/render_modes/persistent/server.rb +27 -26
- data/lib/react_email_rails/render_modes/persistent.rb +9 -11
- data/lib/react_email_rails/render_modes/subprocess/command_runner.rb +1 -1
- data/lib/react_email_rails/render_modes/subprocess.rb +12 -7
- data/lib/react_email_rails/render_protocol.rb +7 -0
- data/lib/react_email_rails/rendered_email.rb +1 -2
- data/lib/react_email_rails/version.rb +1 -1
- data/lib/react_email_rails.rb +29 -24
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ada860a1bfdc970fd871cb2f0d4b89642e42b8641b8d11006ca66e9bf6f452a
|
|
4
|
+
data.tar.gz: 742b838451251d7b97093077ff9909e3d60441e705a841fed522d1d64df83787
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
407
|
+
document: broadcast.body,
|
|
407
408
|
context: { brand_name: "Acme", preview_text: broadcast.subject },
|
|
408
|
-
preview: broadcast.subject,
|
|
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",
|
|
430
|
-
html: params[:body_html],
|
|
431
|
-
context: { brand_name: "Acme" },
|
|
430
|
+
type: "broadcast",
|
|
431
|
+
html: params[:body_html],
|
|
432
|
+
context: { brand_name: "Acme" },
|
|
432
433
|
)
|
|
433
434
|
|
|
434
|
-
broadcast.update!(body: document)
|
|
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,
|
|
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
|
-
|
|
135
|
-
"
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
33
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
22
|
+
ReactEmailRails.configuration.render_process_max_requests
|
|
25
23
|
end
|
|
26
24
|
end
|
|
27
25
|
|
|
@@ -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
|
-
|
|
5
|
+
ReactEmailRails::RenderProtocol.healthy_result?(result)
|
|
6
6
|
rescue StandardError
|
|
7
7
|
false
|
|
8
8
|
end
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
44
|
-
|
|
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`
|
|
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
|
data/lib/react_email_rails.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
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
|
-
|
|
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.
|
|
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
|