rails-domternal 0.1.0 → 0.2.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: 5648277022380eaaf7c2e1f337474924ca9bb1758698d2aa552e7d1ba03f9ae7
4
- data.tar.gz: 9afacf5e4682b49222aef0c38a7fe0b744bf215268cf6564b7ddbc5616a4e6be
3
+ metadata.gz: 1ff0118eed2f74446b2b4d18eae2ae42d905b3875f6bcf1dc253e116a2a10fb0
4
+ data.tar.gz: 355c02ba5479fa07c42242850dd03367c32d7447015deb4b2c8f4a13d265154f
5
5
  SHA512:
6
- metadata.gz: 99b5adcbf3a02f8b2ed0612084ab67581898d743cc3f9c35bff333a4f19c7c8766d3ad1df6d310ea60d8f9398242ceafa1d987c476bccf3e583dbbd49c5a2298
7
- data.tar.gz: 65993a4507b4400482809116c71e611381b2dbcff12cc085f32132e7e69ca8ec7193ec7ca754ccf2bbe0ff9506a0a23f2e41ecaee7cc2b971e515dfec8e69dbb
6
+ metadata.gz: 7762281bd24200520b2e9612b3c7fba7ab032c65e5b6b0dcb632ab1f100f6fe5f66159a67d2cd3d5b460b73c461ea51351ecabbff9c26155507f4e77378effa1
7
+ data.tar.gz: a84961c1d821ae90784fb1fdb8dc75665171fae96a830a591c263bff4b85e6f538a7c8997318a0ddb776c3b3afcde3a18a6779fcdd3c51f9d7922aa957604078
data/README.md CHANGED
@@ -52,6 +52,67 @@ Configure defaults in `config/initializers/domternal.rb` (generated by the insta
52
52
  see `Rails::Domternal::Configuration` for the full list of options (default extensions,
53
53
  toolbar, theme, sanitizer allowlist, upload limits).
54
54
 
55
+ `#to_plain_text` converts a body to text the way ActionText does: block boundaries and
56
+ `<br>` become newlines rather than collapsing into spaces, so line-oriented content (one
57
+ list entry or answer per line) survives, and search columns built from rich text stay
58
+ readable. `blank?`/`present?`/`empty?` delegate to it.
59
+
60
+ ### simple_form
61
+
62
+ If your app has [simple_form](https://github.com/heartcombo/simple_form), `as: :domternal_area`
63
+ works out of the box — the input is registered only when simple_form is loaded, and an
64
+ app-defined `app/inputs/domternal_area_input.rb` takes precedence:
65
+
66
+ ```erb
67
+ <%= f.input :body, as: :domternal_area %>
68
+ ```
69
+
70
+ ### Primary key types
71
+
72
+ `domternal_rich_texts.record_id` holds the primary key of every model declaring
73
+ `has_rich_domternal`, so it has to fit all of them. Both column types default to your
74
+ `config.generators` `primary_key_type`, and can be overridden — but only *before* you run
75
+ the migration, since that's when they're read:
76
+
77
+ ```ruby
78
+ Rails::Domternal.configure do |config|
79
+ config.primary_key_type = :uuid
80
+ config.record_id_type = :string # models don't all share one key type
81
+ end
82
+ ```
83
+
84
+ If some models use uuid primary keys and others bigint, neither fits: use `:string`.
85
+
86
+ ## Migrating from ActionText
87
+
88
+ ```bash
89
+ bin/rails g domternal:migrate_from_action_text
90
+ bin/rails db:migrate
91
+ ```
92
+
93
+ This writes a reversible data migration that copies every `action_text_rich_texts` row into
94
+ `domternal_rich_texts`. Bodies carry over as-is (both store sanitized HTML), and the
95
+ `<action-text-attachment>` nodes Domternal has no equivalent for are rewritten:
96
+
97
+ - Image attachments become `<img>` tags pointing at the Active Storage redirect URL — the
98
+ same markup the editor's own upload handler writes — and the blobs are re-attached as
99
+ `Domternal::RichText` embeds.
100
+ - Your own `ActionText::Attachable` models are dropped unless you say what they should
101
+ render as:
102
+
103
+ ```bash
104
+ bin/rails g domternal:migrate_from_action_text --attachment-partials HtmlTable:content
105
+ ```
106
+
107
+ which inlines `html_table.content` in place of each attachment node.
108
+
109
+ `action_text_rich_texts` is left untouched, so the migration can be rolled back. Then swap
110
+ `has_rich_text` for `has_rich_domternal`, `f.rich_text_area` for `f.domternal_area`, and the
111
+ `.trix-content` class for `.domternal-content`.
112
+
113
+ If you relied on an `ActionText::Attachable` model purely to embed tables, the `:table`
114
+ extension replaces it — tables become real `<table>` markup the editor edits natively.
115
+
55
116
  ### Extensions
56
117
 
57
118
  Extensions are referenced by symbolic name (`:starter_kit`, `:image`, `:table`, `:mention`,
@@ -96,14 +157,43 @@ generates a `@apply`-based version wired into your existing Tailwind build; othe
96
157
  generates plain CSS under `app/assets/stylesheets/`. Force either explicitly with
97
158
  `rails g domternal:install --stylesheet=tailwind` (or `=css`).
98
159
 
160
+ ### Deferred mounting
161
+
162
+ Editors mount when they first become visible, not on `connectedCallback`. Each one loads its
163
+ extension modules and builds a ProseMirror instance, so a form with an editor per locale (or
164
+ per tab, or inside a collapsed section) would otherwise pay for all of them on page load.
165
+
166
+ An editor that hasn't mounted never writes to its hidden input, so the value the server
167
+ rendered is what gets submitted — leaving a tab untouched is always safe. Opt out per editor
168
+ with the `eager` attribute, and use `await element.ready()` to force mounting before driving
169
+ an editor from your own JavaScript.
170
+
99
171
  ### JavaScript delivery
100
172
 
101
- The install generator pins `@domternal/*` packages from [esm.sh](https://esm.sh) by default
102
- for importmap apps. Cross-package peer dependencies (`@domternal/core`, `@domternal/pm`) are
103
- explicitly shared via `?external=` params and a `@domternal/pm/` prefix pin — omitting this
104
- causes ProseMirror's plugin registry to load twice and crash with "Adding different
105
- instances of a keyed plugin." For jsbundling/npm apps, the generator installs the real npm
106
- packages and copies the gem's JS source into `app/javascript/domternal`.
173
+ Two setups, and the choice matters more than it looks:
174
+
175
+ **Importmap (default).** The generator pins `@domternal/*` from [esm.sh](https://esm.sh) at
176
+ an exact version resolved at install time. Cross-package peer dependencies
177
+ (`@domternal/core`, `@domternal/pm`) are explicitly shared via `?external=` params and a
178
+ `@domternal/pm/` prefix pin omitting this loads ProseMirror's plugin registry twice and
179
+ crashes with "Adding different instances of a keyed plugin."
180
+
181
+ This is the quickest way to get running, but the editor is then fetched from a third-party
182
+ CDN in the browser on every page that uses one: an outage or slow response degrades your
183
+ admin UI, and the request graph is deep (the packages pull in ~12 ProseMirror modules).
184
+
185
+ **Bundled (recommended for production).** With a `package.json` present, the generator runs
186
+ `npm add`/`bun add` for the real packages and copies the gem's JS into
187
+ `app/javascript/domternal`, so your bundler resolves and ships everything at build time —
188
+ no runtime CDN, one copy of each dependency guaranteed by the resolver. To move an existing
189
+ importmap app over, add [jsbundling-rails](https://github.com/rails/jsbundling-rails) and
190
+ re-run `rails g domternal:install`, then drop the `@domternal/*` pins from
191
+ `config/importmap.rb`.
192
+
193
+ There's deliberately no "vendor the CDN files into `vendor/javascript`" option: esm.sh URLs
194
+ embed semver ranges and resolve server-side, so downloading that graph correctly means
195
+ reimplementing package resolution, and getting it subtly wrong produces duplicate ProseMirror
196
+ instances that only fail at runtime. Use a bundler for that job.
107
197
 
108
198
  ## Development
109
199
 
@@ -13,17 +13,67 @@ export class DomternalEditorElement extends HTMLElement {
13
13
 
14
14
  connectedCallback() {
15
15
  if (this._mounted) return
16
- this._mounted = true
17
- this._mount()
16
+
17
+ // Editors that aren't on screen yet (locale tabs, accordions, anything display:none)
18
+ // wait to mount. Each one pulls its own extension modules and builds a ProseMirror
19
+ // instance, so a form with one editor per locale would otherwise pay for all of them
20
+ // up front. Setting the `eager` attribute opts out.
21
+ if (this.hasAttribute("eager") || !this._canDeferMount()) {
22
+ this._startMount()
23
+ } else {
24
+ this._observeVisibility()
25
+ }
18
26
  }
19
27
 
20
28
  disconnectedCallback() {
29
+ this._visibilityObserver?.disconnect()
30
+ this._visibilityObserver = null
21
31
  this._toolbar?.destroy()
22
32
  this._bubbleMenu?.destroy()
23
33
  this._floatingMenu?.destroy()
24
34
  this._notionColorPicker?.destroy()
25
35
  this._wrapper?.destroy()
26
36
  this._mounted = false
37
+ this._mountPromise = null
38
+ }
39
+
40
+ // Mounts now if the element is already laid out, otherwise once it first becomes visible.
41
+ _observeVisibility() {
42
+ if (this._isVisible()) {
43
+ this._startMount()
44
+ return
45
+ }
46
+
47
+ this._visibilityObserver = new IntersectionObserver((entries) => {
48
+ if (!entries.some((entry) => entry.isIntersecting)) return
49
+ this._visibilityObserver?.disconnect()
50
+ this._visibilityObserver = null
51
+ this._startMount()
52
+ })
53
+ this._visibilityObserver.observe(this)
54
+ }
55
+
56
+ _canDeferMount() {
57
+ return typeof IntersectionObserver !== "undefined"
58
+ }
59
+
60
+ _isVisible() {
61
+ return this.offsetParent !== null || this.getClientRects().length > 0
62
+ }
63
+
64
+ _startMount() {
65
+ if (!this._mountPromise) {
66
+ this._mounted = true
67
+ this._mountPromise = this._mount()
68
+ }
69
+ return this._mountPromise
70
+ }
71
+
72
+ // Resolves once the editor is mounted, mounting it immediately if it was still deferred.
73
+ // Use before reading `value` or driving the editor from outside.
74
+ async ready() {
75
+ await this._startMount()
76
+ return this
27
77
  }
28
78
 
29
79
  attributeChangedCallback(name, _oldValue, newValue) {
@@ -37,9 +87,11 @@ export class DomternalEditorElement extends HTMLElement {
37
87
  return id ? document.getElementById(id) : null
38
88
  }
39
89
 
40
- // The wrapper's current HTML, or "" before the editor has mounted.
90
+ // The editor's current HTML. Falls back to the linked input while the editor is still
91
+ // deferred or mounting — an unmounted editor never touches the input, so the value the
92
+ // server rendered is still the right answer and submitting the form is always safe.
41
93
  get value() {
42
- return this._wrapper?.htmlContent ?? ""
94
+ return this._wrapper?.htmlContent ?? this.input?.value ?? ""
43
95
  }
44
96
 
45
97
  async _mount() {
@@ -29,8 +29,8 @@ module Domternal
29
29
  options[:class] ||= "domternal-content"
30
30
 
31
31
  options[:data] ||= {}
32
- options[:data][:direct_upload_url] ||= main_app.rails_direct_uploads_url
33
- options[:data][:blob_url_template] ||= main_app.rails_service_blob_url(":signed_id", ":filename")
32
+ options[:data][:direct_upload_url] ||= app_routes.rails_direct_uploads_url
33
+ options[:data][:blob_url_template] ||= app_routes.rails_service_blob_url(":signed_id", ":filename")
34
34
  options[:data][:extensions] ||= extensions.join(" ")
35
35
  options[:data][:toolbar] ||= toolbar.to_s
36
36
  options[:data][:theme] ||= Rails::Domternal.configuration.theme.to_s
@@ -38,7 +38,7 @@ module Domternal
38
38
  options[:data][:allowed_mime_types] ||= Rails::Domternal.configuration.allowed_mime_types.join(" ")
39
39
 
40
40
  editor_tag = content_tag("domternal-editor", "", options)
41
- input_tag = hidden_field_tag(name, value.to_s, id: options[:input], form: form)
41
+ input_tag = hidden_field_tag(name, attribute_value(value), id: options[:input], form: form)
42
42
 
43
43
  input_tag + editor_tag
44
44
  end
@@ -46,6 +46,27 @@ module Domternal
46
46
  def domternal_content(value)
47
47
  content_tag(:div, value.to_s.html_safe, class: "domternal-content") # rubocop:disable Rails/OutputSafety -- sanitized by Domternal::RichText before persistence
48
48
  end
49
+
50
+ private
51
+
52
+ # The body lands in a value="…" attribute, but RichText#to_s is html_safe, and Rails
53
+ # skips escaping for html_safe strings even inside attributes. Handing it over as a plain
54
+ # String restores escaping — without this a body containing a double quote closes the
55
+ # attribute early, losing content on the next edit and allowing attribute injection.
56
+ def attribute_value(value)
57
+ string = value.to_s
58
+ string.html_safe? ? String.new(string) : string
59
+ end
60
+
61
+ # The Active Storage routes live in the host app. `main_app` is the right way to reach
62
+ # them from a view, but it's only defined where the mounted-helpers module has been
63
+ # included, which isn't every context this helper can be called from (mailers, bare
64
+ # ActionView::Base instances, ViewComponent previews).
65
+ def app_routes
66
+ return main_app if respond_to?(:main_app)
67
+
68
+ ::Rails.application.routes.url_helpers
69
+ end
49
70
  end
50
71
  end
51
72
 
@@ -6,6 +6,17 @@ module Domternal
6
6
  # (Domternal's `Editor`/`DomternalEditor` accept HTML directly as `content`), so no
7
7
  # separate JSON<->HTML conversion step is needed on the Ruby side.
8
8
  class RichText < ActiveRecord::Base
9
+ # How #to_plain_text separates elements: paragraph-level blocks get a blank line between
10
+ # them, list items and table rows a single newline, and table cells a space.
11
+ PARAGRAPH_TAGS = %w[
12
+ p div h1 h2 h3 h4 h5 h6 blockquote pre figure figcaption ul ol table details
13
+ ].freeze
14
+ LINE_TAGS = %w[li tr summary].freeze
15
+ CELL_TAGS = %w[td th].freeze
16
+ # Private-use codepoint: not whitespace, so the collapse in #to_plain_text leaves it
17
+ # alone, and Nokogiri accepts it in a text node (unlike NUL).
18
+ BREAK = "\uE000"
19
+
9
20
  self.table_name = "domternal_rich_texts"
10
21
 
11
22
  belongs_to :record, polymorphic: true, touch: true
@@ -20,12 +31,36 @@ module Domternal
20
31
  body.to_s.html_safe # rubocop:disable Rails/OutputSafety -- sanitized in #sanitize_body before persistence
21
32
  end
22
33
 
34
+ # Block-aware, like ActionText's plain text conversion: block boundaries and <br> become
35
+ # newlines rather than being collapsed into spaces. Callers rely on this — line-oriented
36
+ # content (one list item or answer per line) has to survive the round trip, and search
37
+ # index columns built from rich text read far better with the structure kept.
23
38
  def to_plain_text
24
- body.to_s.gsub(/<[^>]*>/, " ").gsub(/\s+/, " ").strip
39
+ html = body.to_s
40
+ return "" if html.blank?
41
+
42
+ fragment = Nokogiri::HTML5.fragment(html)
43
+ # Breaks are marked with a sentinel rather than a real newline so that the whitespace
44
+ # collapse below can't tell them apart from incidental newlines in the source markup.
45
+ fragment.css("br").each { |node| node.replace(text_node(node, BREAK)) }
46
+ fragment.css(*CELL_TAGS).each { |node| node.add_next_sibling(text_node(node, " ")) }
47
+ fragment.css(*LINE_TAGS).each { |node| node.add_next_sibling(text_node(node, BREAK)) }
48
+ fragment.css(*PARAGRAPH_TAGS).each { |node| node.add_next_sibling(text_node(node, BREAK * 2)) }
49
+
50
+ fragment.text
51
+ .gsub(/[^\S\n]+/, " ") # spaces and tabs collapse; newlines in text are content
52
+ .gsub(/ ?(#{BREAK}(?: ?#{BREAK})*) ?/o) { "\n" * Regexp.last_match(1).count(BREAK) }
53
+ .gsub(/ *\n */, "\n")
54
+ .gsub(/\n{3,}/, "\n\n")
55
+ .strip
25
56
  end
26
57
 
27
58
  private
28
59
 
60
+ def text_node(node, content)
61
+ Nokogiri::XML::Text.new(content, node.document)
62
+ end
63
+
29
64
  def sanitize_body
30
65
  return if body.blank?
31
66
 
@@ -47,7 +82,14 @@ module Domternal
47
82
  return if body.blank?
48
83
 
49
84
  signed_ids = body.to_s.scan(%r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/]+)/}).flatten.uniq
50
- self.embeds = signed_ids.filter_map { |signed_id| ActiveStorage::Blob.find_signed(signed_id) }
85
+ blobs = signed_ids.filter_map { |signed_id| ActiveStorage::Blob.find_signed(signed_id) }
86
+
87
+ # Only reassign when the set actually changed: `embeds=` replaces every attachment row
88
+ # and re-runs Active Storage's identify pass (which downloads a chunk of each blob), so
89
+ # doing it on every save of an unchanged body is needless work.
90
+ return if blobs.map(&:id).to_set == embeds_blobs.map(&:id).to_set
91
+
92
+ self.embeds = blobs
51
93
  end
52
94
  end
53
95
  end
@@ -1,25 +1,31 @@
1
1
  class CreateDomternalTables < ActiveRecord::Migration[7.1]
2
2
  def change
3
- primary_key_type, foreign_key_type = primary_and_foreign_key_types
4
-
5
3
  create_table :domternal_rich_texts, id: primary_key_type do |t|
6
4
  t.string :name, null: false
7
5
  t.text :body
8
- t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
6
+ t.references :record, null: false, polymorphic: true, index: false, type: record_id_type
9
7
 
10
8
  t.timestamps
11
9
 
12
- t.index [:record_type, :record_id, :name], name: "index_domternal_rich_texts_uniqueness", unique: true
10
+ t.index [ :record_type, :record_id, :name ], name: "index_domternal_rich_texts_uniqueness", unique: true
13
11
  end
14
12
  end
15
13
 
16
14
  private
17
15
 
18
- def primary_and_foreign_key_types
16
+ def primary_key_type
17
+ Rails::Domternal.configuration.primary_key_type || generators_primary_key_type || :primary_key
18
+ end
19
+
20
+ # `record_id` has to hold the primary key of every model that declares has_rich_domternal.
21
+ # An app whose models don't all share one key type (say uuid alongside bigint) needs
22
+ # :string here — see config.record_id_type in config/initializers/domternal.rb.
23
+ def record_id_type
24
+ Rails::Domternal.configuration.record_id_type || generators_primary_key_type || :bigint
25
+ end
26
+
27
+ def generators_primary_key_type
19
28
  config = Rails.configuration.generators
20
- setting = config.options[config.orm][:primary_key_type]
21
- primary_key_type = setting || :primary_key
22
- foreign_key_type = setting || :bigint
23
- [primary_key_type, foreign_key_type]
29
+ config.options[config.orm][:primary_key_type]
24
30
  end
25
31
  end
@@ -37,8 +37,9 @@ module Domternal
37
37
  @domternal/extension-toc @domternal/extension-block-controls @domternal/extension-details
38
38
  ].index_with { [CORE_PACKAGE, PM_PACKAGE] }.freeze
39
39
 
40
- class_option :npm_version, type: :string, default: "latest",
41
- desc: "Version/dist-tag used when pinning @domternal/* packages"
40
+ class_option :npm_version, type: :string, default: nil,
41
+ desc: "Version/dist-tag used when pinning @domternal/* packages. Defaults to the " \
42
+ "version currently on npm, resolved once so the pins are reproducible."
42
43
 
43
44
  class_option :stylesheet, type: :string, default: nil, enum: %w[css tailwind],
44
45
  desc: "Format for the generated content typography stylesheet. Defaults to " \
@@ -89,8 +90,25 @@ module Domternal
89
90
 
90
91
  private
91
92
 
93
+ # Resolved once and reused for every pin. A dist-tag like "latest" in the importmap
94
+ # would mean the browser silently picks up new editor versions between deploys, and —
95
+ # worse — different @domternal/* packages could resolve to different versions mid-page.
92
96
  def npm_version
93
- options["npm_version"]
97
+ @npm_version ||= options["npm_version"] || latest_npm_version || "latest"
98
+ end
99
+
100
+ def latest_npm_version
101
+ require "net/http"
102
+ require "json"
103
+
104
+ response = Net::HTTP.get_response(URI("https://registry.npmjs.org/@domternal%2Fcore"))
105
+ return unless response.is_a?(Net::HTTPSuccess)
106
+
107
+ JSON.parse(response.body).dig("dist-tags", "latest")
108
+ rescue StandardError => e
109
+ say "Could not reach the npm registry to resolve a version (#{e.class}); " \
110
+ "pinning \"latest\" instead — set an exact version in config/importmap.rb.", :yellow
111
+ nil
94
112
  end
95
113
 
96
114
  def using_importmap?
@@ -139,22 +157,36 @@ module Domternal
139
157
  def wire_up_importmap
140
158
  importmap_path = Pathname(destination_root).join("config/importmap.rb")
141
159
 
142
- pins = +"\n# rails-domternal\n"
160
+ pins = +<<~HEADER
161
+ \n# rails-domternal. The @domternal/* packages are loaded from esm.sh at runtime, so
162
+ # the editor depends on that CDN being reachable from your users' browsers. Every
163
+ # consumer must externalize the same peers, otherwise esm.sh inlines a second copy of
164
+ # ProseMirror and keyed plugins collide. To take the CDN off the critical path, use
165
+ # the jsbundling setup instead — see the README.
166
+ HEADER
143
167
  pins << %(pin_all_from Rails::Domternal::Engine.root.join("app/assets/javascripts/domternal"), under: "domternal", preload: true\n)
144
168
  pins << %(pin "@rails/activestorage", to: "activestorage.esm.js", preload: true\n) unless
145
169
  importmap_path.read.include?('"@rails/activestorage"')
146
170
 
147
- pins << %(pin "#{PM_PACKAGE}/", to: "https://esm.sh/#{PM_PACKAGE}@#{npm_version}/"\n)
171
+ # Nothing from esm.sh is preloaded: element.js imports every @domternal/* package
172
+ # dynamically when an editor mounts, so preloading would make each page — including
173
+ # every page with no editor on it — fetch the editor bundle from the CDN up front.
174
+ pins << %(pin "#{PM_PACKAGE}/", to: "https://esm.sh/#{PM_PACKAGE}@#{npm_version}/", preload: false\n)
148
175
 
149
176
  all_packages.each do |package, externals|
150
177
  url = "https://esm.sh/#{package}@#{npm_version}"
151
178
  url += "?external=#{externals.join(",")}" if externals.present?
152
- preload = package == CORE_PACKAGE
153
- pins << %(pin "#{package}", to: "#{url}"#{preload ? "" : ", preload: false"}\n)
179
+ pins << %(pin "#{package}", to: "#{url}", preload: false\n)
154
180
  end
155
181
 
156
182
  append_to_file importmap_path.to_s, pins
157
- say "Pinned domternal + @domternal/* packages (via esm.sh) in config/importmap.rb", :green
183
+ say "Pinned domternal + @domternal/* packages at #{npm_version} (via esm.sh) in config/importmap.rb", :green
184
+ say <<~WARNING, :yellow
185
+ These pins fetch the editor from esm.sh in the browser at runtime. That's fine for
186
+ getting started, but it puts a third-party CDN on the path of every page with an
187
+ editor. For production, prefer the jsbundling setup (npm + esbuild), which resolves
188
+ and bundles the packages at build time — see "Bundled setup" in the README.
189
+ WARNING
158
190
 
159
191
  application_js = Pathname(destination_root).join("app/javascript/application.js")
160
192
  if application_js.exist?
@@ -21,4 +21,14 @@ Rails::Domternal.configure do |config|
21
21
  # introduce markup not covered by the defaults.
22
22
  # config.sanitizer_allowed_tags += %w[]
23
23
  # config.sanitizer_allowed_attributes += %w[]
24
+
25
+ # Column types for the domternal_rich_texts table, read only when the CreateDomternalTables
26
+ # migration runs — set them BEFORE `bin/rails db:migrate`. Both default to your
27
+ # config.generators primary_key_type, then to Rails' defaults (:primary_key / :bigint).
28
+ #
29
+ # `record_id` stores the primary key of every model declaring has_rich_domternal, so if those
30
+ # models don't all share one key type — a uuid-pk model next to a bigint-pk one — neither
31
+ # :uuid nor :bigint fits and you want :string.
32
+ # config.primary_key_type = :uuid
33
+ # config.record_id_type = :string
24
34
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ module Domternal
6
+ module Generators
7
+ # rails g domternal:migrate_from_action_text
8
+ #
9
+ # Copies every action_text_rich_texts row into domternal_rich_texts, rewriting the
10
+ # <action-text-attachment> nodes that Domternal has no equivalent for. Writes a normal
11
+ # timestamped migration so it runs through the usual deploy path and stays reversible.
12
+ class MigrateFromActionTextGenerator < ::Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ class_option :attachment_partials, type: :hash, default: {},
18
+ desc: "Map of attachable class name to a method returning its replacement HTML, " \
19
+ "e.g. --attachment-partials HtmlTable:content"
20
+
21
+ def create_migration_file
22
+ migration_template "migrate_action_text_to_domternal.rb.tt",
23
+ "db/migrate/migrate_action_text_to_domternal.rb"
24
+ end
25
+
26
+ def show_notes
27
+ say <<~NOTES, :green
28
+
29
+ Review the generated migration before running it. It:
30
+
31
+ * copies bodies verbatim (both use sanitized HTML, so no conversion is needed)
32
+ * turns image attachments into <img> tags on Active Storage redirect URLs, which
33
+ is the same markup the editor's upload handler writes
34
+ * re-attaches the referenced blobs as Domternal::RichText embeds
35
+ * leaves action_text_rich_texts untouched, so you can roll back
36
+
37
+ Non-image attachables (ActionText::Attachable models of your own) are dropped unless
38
+ you tell it what to render — see ATTACHABLE_REPLACEMENTS in the migration.
39
+ NOTES
40
+ end
41
+
42
+ private
43
+
44
+ def attachment_replacements
45
+ options["attachment_partials"]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,117 @@
1
+ require "nokogiri"
2
+
3
+ class MigrateActionTextToDomternal < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ # ActionText represents embeds as <action-text-attachment sgid="...">, which Domternal has
5
+ # no node for. Images become plain <img> pointing at the Active Storage redirect URL — the
6
+ # same markup app/assets/javascripts/domternal/direct_upload.js writes after an upload — so
7
+ # the editor round-trips them like any other image.
8
+ #
9
+ # Attachables of your own (ActionText::Attachable models) can't be guessed at. Map the class
10
+ # name to something returning replacement HTML, or leave it out and the node is dropped:
11
+ #
12
+ # ATTACHABLE_REPLACEMENTS = {
13
+ # "HtmlTable" => ->(record) { record.content },
14
+ # }.freeze
15
+ ATTACHABLE_REPLACEMENTS = {
16
+ <% attachment_replacements.each do |klass, method| -%>
17
+ "<%= klass %>" => ->(record) { record.<%= method %> },
18
+ <% end -%>
19
+ }.freeze
20
+
21
+ # Read through bare AR models rather than the app's classes: ActionText::RichText#body
22
+ # returns rendered ActionText::Content, and the attachable class may already be gone.
23
+ class LegacyRichText < ActiveRecord::Base
24
+ self.table_name = "action_text_rich_texts"
25
+ end
26
+
27
+ def up
28
+ say "migrating #{LegacyRichText.count} action_text_rich_texts rows"
29
+ migrated = skipped = 0
30
+
31
+ # Nothing here should bump parent timestamps.
32
+ ActiveRecord::Base.no_touching do
33
+ LegacyRichText.find_each do |source|
34
+ if already_migrated?(source)
35
+ skipped += 1
36
+ next
37
+ end
38
+
39
+ Domternal::RichText.create!(
40
+ record_type: source.record_type,
41
+ record_id: source.record_id,
42
+ name: source.name,
43
+ body: convert(source.body),
44
+ created_at: source.created_at,
45
+ updated_at: source.updated_at
46
+ )
47
+ migrated += 1
48
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e
49
+ skipped += 1
50
+ say " skipped #{source.record_type}##{source.record_id} #{source.name}: #{e.message}"
51
+ end
52
+ end
53
+
54
+ say "migrated #{migrated}, skipped #{skipped}"
55
+ end
56
+
57
+ def down
58
+ Domternal::RichText.delete_all
59
+ end
60
+
61
+ private
62
+
63
+ def already_migrated?(source)
64
+ Domternal::RichText.exists?(
65
+ record_type: source.record_type, record_id: source.record_id, name: source.name
66
+ )
67
+ end
68
+
69
+ def convert(html)
70
+ return html if html.blank? || !html.include?("action-text-attachment")
71
+
72
+ fragment = Nokogiri::HTML5.fragment(html)
73
+ fragment.css("action-text-attachment").each do |node|
74
+ replacement = replacement_for(node)
75
+ replacement ? node.replace(replacement) : node.remove
76
+ end
77
+ fragment.to_html
78
+ end
79
+
80
+ # The sgid is parsed rather than located, so an attachable class that no longer exists
81
+ # yields nil instead of raising NameError.
82
+ def replacement_for(node)
83
+ return if node["sgid"].blank?
84
+
85
+ gid = SignedGlobalID.parse(node["sgid"], for: "attachable")
86
+ return if gid.nil?
87
+
88
+ if gid.model_name == "ActiveStorage::Blob"
89
+ blob = ActiveStorage::Blob.find_by(id: gid.model_id)
90
+ blob && image_html(blob, node)
91
+ else
92
+ custom_replacement(gid)
93
+ end
94
+ end
95
+
96
+ def custom_replacement(gid)
97
+ replacement = ATTACHABLE_REPLACEMENTS[gid.model_name]
98
+ return unless replacement
99
+
100
+ record = gid.model_name.safe_constantize&.find_by(id: gid.model_id)
101
+ record && replacement.call(record)
102
+ end
103
+
104
+ def image_html(blob, node)
105
+ return unless blob.content_type.to_s.start_with?("image/")
106
+
107
+ filename = node["filename"].presence || blob.filename.to_s
108
+ url = "/rails/active_storage/blobs/redirect/#{blob.signed_id}/#{ERB::Util.url_encode(filename)}"
109
+ alt = node["alt"].presence || filename
110
+ img = %(<img src="#{ERB::Util.html_escape(url)}" alt="#{ERB::Util.html_escape(alt)}">)
111
+
112
+ caption = node["caption"].presence
113
+ return img if caption.blank?
114
+
115
+ %(<figure>#{img}<figcaption>#{ERB::Util.html_escape(caption)}</figcaption></figure>)
116
+ end
117
+ end
@@ -24,6 +24,15 @@ module Rails
24
24
  attr_accessor :allowed_mime_types
25
25
  attr_accessor :max_file_size
26
26
 
27
+ # Column types used by the CreateDomternalTables migration. Both default to the app's
28
+ # `config.generators` primary_key_type, then to Rails' defaults.
29
+ #
30
+ # Set record_id_type to :string if the models declaring has_rich_domternal don't all
31
+ # share one primary key type — a uuid-pk model and a bigint-pk model can't both be
32
+ # referenced by a typed polymorphic column. Only read at migration time.
33
+ attr_accessor :primary_key_type
34
+ attr_accessor :record_id_type
35
+
27
36
  # HTML tags/attributes allowed through the safelist sanitizer applied to incoming bodies.
28
37
  attr_accessor :sanitizer_allowed_tags
29
38
  attr_accessor :sanitizer_allowed_attributes
@@ -36,6 +45,8 @@ module Rails
36
45
  @npm_version = "latest"
37
46
  @allowed_mime_types = %w[image/png image/jpeg image/gif image/webp image/svg+xml image/avif]
38
47
  @max_file_size = 10.megabytes
48
+ @primary_key_type = nil
49
+ @record_id_type = nil
39
50
 
40
51
  @sanitizer_allowed_tags = %w[
41
52
  p br div span
@@ -32,6 +32,18 @@ module Rails
32
32
  include Rails::Domternal::Attribute
33
33
  end
34
34
  end
35
+
36
+ # Opt-in by presence: apps using simple_form get `as: :domternal_area` for free, apps
37
+ # without it never load the input. Runs after initialization so an app's own
38
+ # app/inputs/domternal_area_input.rb wins.
39
+ initializer "domternal.simple_form" do |app|
40
+ app.config.after_initialize do
41
+ next unless defined?(::SimpleForm)
42
+ next if Object.const_defined?(:DomternalAreaInput)
43
+
44
+ require "rails/domternal/simple_form_input"
45
+ end
46
+ end
35
47
  end
36
48
  end
37
49
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded only when the host app has simple_form (see Engine's "domternal.simple_form"
4
+ # initializer). simple_form resolves `as: :domternal_area` by constantizing
5
+ # "DomternalAreaInput" at the top level, which is why this isn't namespaced.
6
+ #
7
+ # <%= simple_form_for @post do |f| %>
8
+ # <%= f.input :body, as: :domternal_area %>
9
+ # <% end %>
10
+ #
11
+ # An app-defined app/inputs/domternal_area_input.rb takes precedence.
12
+ class DomternalAreaInput < SimpleForm::Inputs::Base
13
+ def input(wrapper_options = nil)
14
+ merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
15
+ @builder.domternal_area(attribute_name, merged_input_options)
16
+ end
17
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rails
4
4
  module Domternal
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-domternal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan
@@ -47,12 +47,15 @@ files:
47
47
  - lib/generators/domternal/install/templates/domternal_content.css.erb
48
48
  - lib/generators/domternal/install/templates/domternal_content.tailwind.css.erb
49
49
  - lib/generators/domternal/install/templates/initializer.rb
50
+ - lib/generators/domternal/migrate_from_action_text/migrate_from_action_text_generator.rb
51
+ - lib/generators/domternal/migrate_from_action_text/templates/migrate_action_text_to_domternal.rb.tt
50
52
  - lib/rails-domternal.rb
51
53
  - lib/rails/domternal.rb
52
54
  - lib/rails/domternal/attribute.rb
53
55
  - lib/rails/domternal/configuration.rb
54
56
  - lib/rails/domternal/engine.rb
55
57
  - lib/rails/domternal/extension_registry.rb
58
+ - lib/rails/domternal/simple_form_input.rb
56
59
  - lib/rails/domternal/version.rb
57
60
  - sig/rails/domternal.rbs
58
61
  homepage: https://github.com/getrestful/rails-domternal