react-email-rails 0.2.0 → 0.3.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: 52da00b9618d5b217b66af2598edf2a25fa9d0850447bfc95d8637c778837583
4
+ data.tar.gz: 84bcdbd09672b293b85d65bbde9779c0d0d2c2c282dd83e72cc2412d38150c6b
5
5
  SHA512:
6
- metadata.gz: 1fbbbb75215e8b4b77de4d4c15e533348e2b7195196d5b9401541596e24507b3c046ca8f74998f20b76f9fbf6d9b67d8c0ec29a86af3aaa64e0b1b70bece4c85
7
- data.tar.gz: 5e7025b1552256c5d1b2fd01a911c7d941b2fd41aa6cd94ddd646fbeb79713888dd6dc13785ff71c66cadb9362877e2abe4715855630813314e7073f509ccccc
6
+ metadata.gz: b9ce2a8643e90776bee1f37c9758fd9d853465d37b73184e0ebdeb20a87e33d9d686354b1a8256cb3bb4887221d25b7c14c9b6733d905c04e86f3fa2d1e06cc7
7
+ data.tar.gz: 1257c29d6639ed5a1559d23d64316bea2c6ccf075b365e816dc0bfd41cb996003e466dbabe97bc50aa9b736e2a45e81e083cd908791f91d8d94f13b239e65f67
data/CHANGELOG.md CHANGED
@@ -1,13 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Add `ReactEmailRails.parse` to convert semantic HTML into a canonical `@react-email/editor` document using a renderer's extensions.
6
+ - Add `@tiptap/html` and `happy-dom` as optional peer dependencies, required only when calling `parse`; compose-only document rendering does not require them.
7
+ - Bump the render protocol to 3 (the renderer now accepts parse requests). The Ruby gem and npm package must be upgraded together, as before.
8
+
3
9
  ## 0.2.0
4
10
 
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.
11
+ - Add `ReactEmailRails.compose` for server-side rendering of `@react-email/editor` documents to HTML and text.
12
+ - Add the `documents` Vite plugin option for discovering document renderers, parallel to `emails`.
13
+ - Report document nodes that render to nothing as non-fatal warnings on the result and instrumentation payload.
8
14
  - Add `@react-email/editor` and `@tiptap/core` as optional peer dependencies (only required when rendering documents).
9
15
  - 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) { ... }`.
16
+ - **Breaking:** `on_render_error` callbacks now receive `(error, **context)` with `kind:` plus `component:` for emails or `type:` for documents.
11
17
 
12
18
  ## 0.1.3
13
19
 
data/README.md CHANGED
@@ -311,6 +311,12 @@ Install the editor packages:
311
311
  npm i @react-email/editor @tiptap/core
312
312
  ```
313
313
 
314
+ To also parse HTML into documents with [`parse`](#parsing-html-into-a-document), add `@tiptap/html` and its server DOM, `happy-dom`:
315
+
316
+ ```sh
317
+ npm i @tiptap/html happy-dom
318
+ ```
319
+
314
320
  Enable the `documents` option in your Vite config:
315
321
 
316
322
  ```ts
@@ -397,7 +403,7 @@ broadcast = Broadcast.find(params[:id])
397
403
 
398
404
  rendered = ReactEmailRails.compose(
399
405
  type: "broadcast",
400
- document: broadcast.body, # Tiptap JSON, e.g. a jsonb column
406
+ document: broadcast.body, # a Hash in Tiptap's shape, e.g. from a jsonb column
401
407
  context: { brand_name: "Acme", preview_text: broadcast.subject },
402
408
  preview: broadcast.subject, # optional; falls back to getPreview(context)
403
409
  )
@@ -408,10 +414,40 @@ rendered.text # => "ACME\n\n..."
408
414
 
409
415
  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
416
 
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.
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
+
419
+ **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
420
 
413
421
  `render_options` does not apply to documents; `composeReactEmail` controls its own rendering.
414
422
 
423
+ ### Parsing HTML into a Document
424
+
425
+ `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
+ ```ruby
428
+ 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
432
+ )
433
+
434
+ broadcast.update!(body: document) # persist the Hash (e.g. to a jsonb column)
435
+ ```
436
+
437
+ Later, render the stored document like any other:
438
+
439
+ ```ruby
440
+ rendered = ReactEmailRails.compose(type: "broadcast", document: broadcast.body)
441
+ ```
442
+
443
+ `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
+ What this means in practice:
446
+
447
+ - 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.
449
+ - If you already have the document `Hash`, pass it to `compose` directly.
450
+
415
451
  ### Debugging Dropped Content
416
452
 
417
453
  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 +546,7 @@ end
510
546
 
511
547
  #### Error Reporting
512
548
 
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:
549
+ 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
550
 
515
551
  ```ruby
516
552
  ReactEmailRails.configure do |config|
@@ -522,7 +558,7 @@ end
522
558
 
523
559
  #### Instrumentation
524
560
 
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)):
561
+ 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
562
 
527
563
  ```ruby
528
564
  ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
@@ -81,6 +81,7 @@ class ReactEmailRails::RenderModes::Persistent::Server
81
81
  body[:html] = response["html"] if response.key?("html")
82
82
  body[:text] = response["text"] if response.key?("text")
83
83
  body[:warnings] = response["warnings"] if response.key?("warnings")
84
+ body[:document] = response["document"] if response.key?("document")
84
85
  end,
85
86
  ))
86
87
  rescue JSON::ParserError => e
@@ -10,9 +10,12 @@ class ReactEmailRails::RenderModes::Subprocess
10
10
 
11
11
  # Payload-agnostic transport: the caller builds and serializes the payload.
12
12
  # `label` identifies the render in error messages (component name or document type).
13
- def initialize(payload:, label:)
13
+ # `response` selects how the renderer's reply is interpreted: `:email` builds a
14
+ # RenderedEmail (render/compose), `:document` returns the parsed document (parse).
15
+ def initialize(payload:, label:, response: :email)
14
16
  @payload = payload
15
17
  @label = label
18
+ @response = response
16
19
  end
17
20
 
18
21
  def render
@@ -21,15 +24,15 @@ class ReactEmailRails::RenderModes::Subprocess
21
24
 
22
25
  private
23
26
 
24
- attr_reader(:payload, :label)
27
+ attr_reader(:payload, :label, :response)
25
28
 
26
29
  def run
27
30
  result = capture(payload_json)
28
31
  raise(render_error(error_message(result.stderr, result.status))) unless result.status.success?
29
32
 
30
33
  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))
34
+ validate_metadata!(body)
35
+ build_result(body)
33
36
  rescue JSON::ParserError => e
34
37
  raise(render_error("render process returned invalid JSON: #{e.message}"))
35
38
  rescue KeyError => e
@@ -75,12 +78,31 @@ class ReactEmailRails::RenderModes::Subprocess
75
78
  raise(render_error("email bundle not found at #{bundle_path.inspect}; run react-email-rails-build before rendering React emails"))
76
79
  end
77
80
 
78
- def validate_response!(body)
79
- raise(render_error(ReactEmailRails::RenderProtocol.mismatch_message(body))) unless ReactEmailRails::RenderProtocol.compatible_metadata?(body)
81
+ def validate_metadata!(body)
82
+ return if ReactEmailRails::RenderProtocol.compatible_metadata?(body)
83
+
84
+ raise(render_error(ReactEmailRails::RenderProtocol.mismatch_message(body)))
85
+ end
86
+
87
+ def build_result(body)
88
+ response == :document ? build_document(body) : build_rendered_email(body)
89
+ end
80
90
 
91
+ def build_rendered_email(body)
81
92
  raise(KeyError.new(key: "html")) unless body.key?("html")
82
93
  raise(render_error("render process returned an invalid response: html must be a string")) unless body["html"].is_a?(String)
83
94
  raise(render_error("render process returned an invalid response: text must be a string")) if body.key?("text") && !body["text"].is_a?(String)
95
+
96
+ ReactEmailRails::RenderedEmail.new(html: body.fetch("html"), text: body["text"].to_s, warnings: warnings_from(body))
97
+ end
98
+
99
+ def build_document(body)
100
+ raise(KeyError.new(key: "document")) unless body.key?("document")
101
+
102
+ document = body.fetch("document")
103
+ raise(render_error("parse process returned an invalid response: document must be an object")) unless document.is_a?(Hash)
104
+
105
+ document
84
106
  end
85
107
 
86
108
  def warnings_from(body)
@@ -1,5 +1,5 @@
1
1
  module ReactEmailRails
2
- RENDER_PROTOCOL_VERSION = 2
2
+ RENDER_PROTOCOL_VERSION = 3
3
3
 
4
4
  module RenderProtocol
5
5
  extend(self)
@@ -1,3 +1,3 @@
1
1
  module ReactEmailRails
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -67,6 +67,23 @@ module ReactEmailRails
67
67
  raise
68
68
  end
69
69
 
70
+ # Parse HTML into an editor document Hash using the renderer's extensions.
71
+ def parse(type:, html:, context: {})
72
+ payload = {
73
+ kind: "parse",
74
+ type:,
75
+ html: html.to_s,
76
+ context: serialized_props(context),
77
+ }
78
+
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
85
+ end
86
+
70
87
  def healthy?
71
88
  configuration.resolved_render_mode.healthy?(
72
89
  command: configuration.send(:resolved_render_command),
@@ -84,9 +101,11 @@ module ReactEmailRails
84
101
 
85
102
  def instrument(**metadata)
86
103
  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?
104
+ yield.tap do |result|
105
+ next unless result.respond_to?(:html)
106
+
107
+ payload[:html_bytes] = result.html.bytesize
108
+ payload[:warnings] = result.warnings if result.warnings.present?
90
109
  end
91
110
  end
92
111
  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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supertape