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 +4 -4
- data/README.md +96 -6
- data/app/assets/javascripts/domternal/element.js +56 -4
- data/app/helpers/domternal/tag_helper.rb +24 -3
- data/app/models/domternal/rich_text.rb +44 -2
- data/db/migrate/20260101000000_create_domternal_tables.rb +15 -9
- data/lib/generators/domternal/install/install_generator.rb +40 -8
- data/lib/generators/domternal/install/templates/initializer.rb +10 -0
- data/lib/generators/domternal/migrate_from_action_text/migrate_from_action_text_generator.rb +49 -0
- data/lib/generators/domternal/migrate_from_action_text/templates/migrate_action_text_to_domternal.rb.tt +117 -0
- data/lib/rails/domternal/configuration.rb +11 -0
- data/lib/rails/domternal/engine.rb +12 -0
- data/lib/rails/domternal/simple_form_input.rb +17 -0
- data/lib/rails/domternal/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ff0118eed2f74446b2b4d18eae2ae42d905b3875f6bcf1dc253e116a2a10fb0
|
|
4
|
+
data.tar.gz: 355c02ba5479fa07c42242850dd03367c32d7447015deb4b2c8f4a13d265154f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
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] ||=
|
|
33
|
-
options[:data][:blob_url_template] ||=
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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.
|
|
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
|