rails-domternal 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +123 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/domternal/direct_upload.js +26 -0
- data/app/assets/javascripts/domternal/element.js +135 -0
- data/app/assets/javascripts/domternal/extensions.js +56 -0
- data/app/assets/javascripts/domternal/index.js +7 -0
- data/app/helpers/domternal/tag_helper.rb +91 -0
- data/app/models/domternal/rich_text.rb +53 -0
- data/db/migrate/20260101000000_create_domternal_tables.rb +25 -0
- data/lib/generators/domternal/install/install_generator.rb +193 -0
- data/lib/generators/domternal/install/templates/domternal.css.erb +27 -0
- data/lib/generators/domternal/install/templates/domternal_content.css.erb +56 -0
- data/lib/generators/domternal/install/templates/domternal_content.tailwind.css.erb +36 -0
- data/lib/generators/domternal/install/templates/initializer.rb +24 -0
- data/lib/rails/domternal/attribute.rb +71 -0
- data/lib/rails/domternal/configuration.rb +56 -0
- data/lib/rails/domternal/engine.rb +37 -0
- data/lib/rails/domternal/extension_registry.rb +37 -0
- data/lib/rails/domternal/version.rb +7 -0
- data/lib/rails/domternal.rb +28 -0
- data/lib/rails-domternal.rb +3 -0
- data/sig/rails/domternal.rbs +6 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5648277022380eaaf7c2e1f337474924ca9bb1758698d2aa552e7d1ba03f9ae7
|
|
4
|
+
data.tar.gz: 9afacf5e4682b49222aef0c38a7fe0b744bf215268cf6564b7ddbc5616a4e6be
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 99b5adcbf3a02f8b2ed0612084ab67581898d743cc3f9c35bff333a4f19c7c8766d3ad1df6d310ea60d8f9398242ceafa1d987c476bccf3e583dbbd49c5a2298
|
|
7
|
+
data.tar.gz: 65993a4507b4400482809116c71e611381b2dbcff12cc085f32132e7e69ca8ec7193ec7ca754ccf2bbe0ff9506a0a23f2e41ecaee7cc2b971e515dfec8e69dbb
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Copyright 2026 Stefan
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Rails::Domternal
|
|
2
|
+
|
|
3
|
+
A [Domternal](https://github.com/domternal/domternal) (ProseMirror-based) rich text editor
|
|
4
|
+
integration for Rails, shaped as a drop-in replacement for ActionText:
|
|
5
|
+
|
|
6
|
+
- `has_rich_domternal :body` — a dependent rich text model, mirroring `has_rich_text`.
|
|
7
|
+
- `f.domternal_area :body` / `domternal_content post.body` — form and display helpers,
|
|
8
|
+
mirroring `rich_text_area` / the `<trix-editor>` tag pair.
|
|
9
|
+
- `<domternal-editor>` — a self-registering custom element (no Stimulus dependency) that
|
|
10
|
+
mounts the editor and syncs a hidden input on every change.
|
|
11
|
+
- Active Storage-backed image uploads, wired through the same
|
|
12
|
+
`data-direct-upload-url` / `data-blob-url-template` convention ActionText uses.
|
|
13
|
+
- `rails g domternal:install` — detects importmap-rails vs. a Node/Bun bundler and wires up
|
|
14
|
+
the JS accordingly.
|
|
15
|
+
|
|
16
|
+
See `demo/` for a working Rails 8 app (importmap + Propshaft) with a `Post` model using
|
|
17
|
+
`has_rich_domternal`.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Add to your Gemfile:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "rails-domternal"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle install
|
|
31
|
+
bin/rails g domternal:install
|
|
32
|
+
bin/rails db:migrate
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class Post < ApplicationRecord
|
|
39
|
+
has_rich_domternal :body, extensions: %i[starter_kit image table]
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```erb
|
|
44
|
+
<%= form_with model: @post do |form| %>
|
|
45
|
+
<%= form.domternal_area :body %>
|
|
46
|
+
<% end %>
|
|
47
|
+
|
|
48
|
+
<%= domternal_content @post.body %>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Configure defaults in `config/initializers/domternal.rb` (generated by the install task) —
|
|
52
|
+
see `Rails::Domternal::Configuration` for the full list of options (default extensions,
|
|
53
|
+
toolbar, theme, sanitizer allowlist, upload limits).
|
|
54
|
+
|
|
55
|
+
### Extensions
|
|
56
|
+
|
|
57
|
+
Extensions are referenced by symbolic name (`:starter_kit`, `:image`, `:table`, `:mention`,
|
|
58
|
+
`:markdown`, `:emoji`, `:math`, `:code_block`, `:toc`, `:block_controls`, `:details`),
|
|
59
|
+
resolved server-side by `Rails::Domternal::ExtensionRegistry` (fails fast on typos) and
|
|
60
|
+
lazy-loaded client-side by `app/assets/javascripts/domternal/extensions.js`. Register a
|
|
61
|
+
custom extension with:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Rails::Domternal.extension_registry.register(:my_extension)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { registerExtension } from "domternal/extensions"
|
|
69
|
+
registerExtension("my_extension", async () => (await import("my-extension-package")).MyExtension)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Toolbar modes
|
|
73
|
+
|
|
74
|
+
`toolbar:` (on `has_rich_domternal`, `domternal_area`, or the global config) accepts:
|
|
75
|
+
|
|
76
|
+
- `:full` (default) / `:minimal` — a fixed `DomternalToolbar`.
|
|
77
|
+
- `:notion` — no fixed toolbar; instead a selection bubble menu handles inline formatting
|
|
78
|
+
and a "+" floating menu (shown only via the block handle's button) inserts blocks. Pair
|
|
79
|
+
this with the `:block_controls` extension, which supplies the drag handle, block context
|
|
80
|
+
menu, slash command, smart paste, and keyboard reorder:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
has_rich_domternal :internal_notes, extensions: %i[starter_kit block_controls toc], toolbar: :notion
|
|
84
|
+
```
|
|
85
|
+
- `false` — no chrome at all, just the raw editable surface.
|
|
86
|
+
|
|
87
|
+
### Content styling
|
|
88
|
+
|
|
89
|
+
`rails g domternal:install` also generates a baseline typography stylesheet (headings,
|
|
90
|
+
lists, blockquotes, code, tables — none of which Domternal's own theme opinionates on)
|
|
91
|
+
scoped to `.domternal-content`/`<domternal-editor>`, covering both the live editor and
|
|
92
|
+
`domternal_content` read-only output. It's generated once and meant to be edited freely.
|
|
93
|
+
|
|
94
|
+
By default it autodetects `tailwindcss-rails` (`app/assets/tailwind/application.css`) and
|
|
95
|
+
generates a `@apply`-based version wired into your existing Tailwind build; otherwise it
|
|
96
|
+
generates plain CSS under `app/assets/stylesheets/`. Force either explicitly with
|
|
97
|
+
`rails g domternal:install --stylesheet=tailwind` (or `=css`).
|
|
98
|
+
|
|
99
|
+
### JavaScript delivery
|
|
100
|
+
|
|
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`.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test`
|
|
111
|
+
to run the tests. You can also run `bin/console` for an interactive prompt.
|
|
112
|
+
|
|
113
|
+
To try it against a real app, see `demo/` — it references this gem via a `path:` Gemfile
|
|
114
|
+
entry, so changes to the gem are picked up immediately.
|
|
115
|
+
|
|
116
|
+
## Contributing
|
|
117
|
+
|
|
118
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
119
|
+
https://github.com/getrestful/rails-domternal.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Bridges the Domternal image extension's `uploadHandler` (a `(file) => Promise<string url>`
|
|
2
|
+
// callback) to Active Storage direct uploads, matching the convention ActionText's
|
|
3
|
+
// rich_textarea_tag uses for its `data-direct-upload-url` / `data-blob-url-template`.
|
|
4
|
+
import { DirectUpload } from "@rails/activestorage"
|
|
5
|
+
|
|
6
|
+
export function createUploadHandler({ directUploadUrl, blobUrlTemplate, onUploadError }) {
|
|
7
|
+
return function uploadHandler(file) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const upload = new DirectUpload(file, directUploadUrl)
|
|
10
|
+
|
|
11
|
+
upload.create((error, blob) => {
|
|
12
|
+
if (error) {
|
|
13
|
+
onUploadError?.(error, file)
|
|
14
|
+
reject(error)
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const url = blobUrlTemplate
|
|
19
|
+
.replace(":signed_id", blob.signed_id)
|
|
20
|
+
.replace(":filename", encodeURIComponent(blob.filename))
|
|
21
|
+
|
|
22
|
+
resolve(url)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { resolveExtensions } from "domternal/extensions"
|
|
2
|
+
import { createUploadHandler } from "domternal/direct_upload"
|
|
3
|
+
|
|
4
|
+
// <domternal-editor input="..." data-extensions="starter_kit image" data-toolbar="full" ...>
|
|
5
|
+
//
|
|
6
|
+
// Mirrors <trix-editor>: a self-registering custom element that mounts the editor and
|
|
7
|
+
// keeps a linked hidden <input> in sync on every change, so it drops into a form exactly
|
|
8
|
+
// like ActionText's rich_textarea_tag output does.
|
|
9
|
+
export class DomternalEditorElement extends HTMLElement {
|
|
10
|
+
static get observedAttributes() {
|
|
11
|
+
return ["editable"]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
connectedCallback() {
|
|
15
|
+
if (this._mounted) return
|
|
16
|
+
this._mounted = true
|
|
17
|
+
this._mount()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnectedCallback() {
|
|
21
|
+
this._toolbar?.destroy()
|
|
22
|
+
this._bubbleMenu?.destroy()
|
|
23
|
+
this._floatingMenu?.destroy()
|
|
24
|
+
this._notionColorPicker?.destroy()
|
|
25
|
+
this._wrapper?.destroy()
|
|
26
|
+
this._mounted = false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
attributeChangedCallback(name, _oldValue, newValue) {
|
|
30
|
+
if (name === "editable" && this._wrapper) {
|
|
31
|
+
this._wrapper.setEditable(newValue !== "false")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get input() {
|
|
36
|
+
const id = this.getAttribute("input")
|
|
37
|
+
return id ? document.getElementById(id) : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The wrapper's current HTML, or "" before the editor has mounted.
|
|
41
|
+
get value() {
|
|
42
|
+
return this._wrapper?.htmlContent ?? ""
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async _mount() {
|
|
46
|
+
const input = this.input
|
|
47
|
+
if (!input) {
|
|
48
|
+
throw new Error(`[domternal] <domternal-editor> could not find its input (input="${this.getAttribute("input")}")`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.classList.add("dm-editor")
|
|
52
|
+
this.innerHTML = `
|
|
53
|
+
<div class="domternal-toolbar" part="toolbar"></div>
|
|
54
|
+
<div class="domternal-surface" part="surface"></div>
|
|
55
|
+
`
|
|
56
|
+
const toolbarHost = this.querySelector(".domternal-toolbar")
|
|
57
|
+
const surfaceHost = this.querySelector(".domternal-surface")
|
|
58
|
+
|
|
59
|
+
const extensionNames = (this.dataset.extensions || "starter_kit image").split(/\s+/).filter(Boolean)
|
|
60
|
+
const uploadHandler = createUploadHandler({
|
|
61
|
+
directUploadUrl: this.dataset.directUploadUrl,
|
|
62
|
+
blobUrlTemplate: this.dataset.blobUrlTemplate,
|
|
63
|
+
onUploadError: (error, file) => this._dispatchUploadError(error, file)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const extensions = await resolveExtensions(extensionNames, {
|
|
67
|
+
image: {
|
|
68
|
+
uploadHandler,
|
|
69
|
+
allowedMimeTypes: (this.dataset.allowedMimeTypes || "").split(/\s+/).filter(Boolean) || undefined,
|
|
70
|
+
maxFileSize: this.dataset.maxFileSize ? Number(this.dataset.maxFileSize) : undefined
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const { DomternalEditor, DomternalToolbar, DomternalBubbleMenu, DomternalFloatingMenu, DomternalNotionColorPicker } =
|
|
75
|
+
await import("@domternal/vanilla")
|
|
76
|
+
|
|
77
|
+
this._wrapper = new DomternalEditor(surfaceHost, {
|
|
78
|
+
extensions,
|
|
79
|
+
content: input.value,
|
|
80
|
+
editable: this.getAttribute("editable") !== "false",
|
|
81
|
+
onUpdate: ({ editor }) => this._syncInput(editor),
|
|
82
|
+
onFocus: () => this._dispatch("domternal:focus"),
|
|
83
|
+
onBlur: () => this._dispatch("domternal:blur")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const toolbarMode = this.dataset.toolbar || "full"
|
|
87
|
+
const editor = this._wrapper.editor
|
|
88
|
+
|
|
89
|
+
if (toolbarMode === "notion") {
|
|
90
|
+
// Notion-style: no fixed toolbar. A selection bubble menu handles inline formatting,
|
|
91
|
+
// a "+" floating menu (triggered only via the block handle's button, not on every
|
|
92
|
+
// empty paragraph) inserts blocks, and the block handle/slash command/drag-reorder
|
|
93
|
+
// themselves come from the "block_controls" extensions — add that to `extensions:`
|
|
94
|
+
// on has_rich_domternal for this to have anything to control.
|
|
95
|
+
toolbarHost.remove()
|
|
96
|
+
|
|
97
|
+
const bubbleHost = document.createElement("div")
|
|
98
|
+
bubbleHost.className = "dm-bubble-menu"
|
|
99
|
+
this.appendChild(bubbleHost)
|
|
100
|
+
this._bubbleMenu = new DomternalBubbleMenu(bubbleHost, { editor })
|
|
101
|
+
|
|
102
|
+
const floatingHost = document.createElement("div")
|
|
103
|
+
floatingHost.className = "dm-floating-menu"
|
|
104
|
+
this.appendChild(floatingHost)
|
|
105
|
+
this._floatingMenu = new DomternalFloatingMenu(floatingHost, { editor, requireExplicitTrigger: true })
|
|
106
|
+
|
|
107
|
+
this._notionColorPicker = new DomternalNotionColorPicker({ editor })
|
|
108
|
+
} else if (toolbarMode !== "false") {
|
|
109
|
+
this._toolbar = new DomternalToolbar(toolbarHost, { editor })
|
|
110
|
+
} else {
|
|
111
|
+
toolbarHost.remove()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this._dispatch("domternal:initialize")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_syncInput(editor) {
|
|
118
|
+
const input = this.input
|
|
119
|
+
if (!input) return
|
|
120
|
+
input.value = editor.getHTML()
|
|
121
|
+
this._dispatch("domternal:change")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_dispatch(type, detail = {}) {
|
|
125
|
+
this.dispatchEvent(new CustomEvent(type, { bubbles: true, detail: { element: this, ...detail } }))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_dispatchUploadError(error, file) {
|
|
129
|
+
this._dispatch("domternal:upload-error", { error, file })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!customElements.get("domternal-editor")) {
|
|
134
|
+
customElements.define("domternal-editor", DomternalEditorElement)
|
|
135
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Symbolic extension names -> lazy-loaded Domternal extensions. Kept in sync with
|
|
2
|
+
// Rails::Domternal::ExtensionRegistry (lib/rails/domternal/extension_registry.rb), which
|
|
3
|
+
// validates `has_rich_domternal extensions: [...]` server-side against the same names.
|
|
4
|
+
const registry = new Map([
|
|
5
|
+
["starter_kit", async () => (await import("@domternal/core")).StarterKit],
|
|
6
|
+
["image", async (options) => {
|
|
7
|
+
const { Image } = await import("@domternal/extension-image")
|
|
8
|
+
return Image.configure(options.image || {})
|
|
9
|
+
}],
|
|
10
|
+
["table", async () => {
|
|
11
|
+
const { Table, TableRow, TableCell, TableHeader } = await import("@domternal/extension-table")
|
|
12
|
+
return [Table, TableRow, TableCell, TableHeader]
|
|
13
|
+
}],
|
|
14
|
+
["mention", async () => (await import("@domternal/extension-mention")).Mention],
|
|
15
|
+
["markdown", async () => (await import("@domternal/extension-markdown")).Markdown],
|
|
16
|
+
["emoji", async () => (await import("@domternal/extension-emoji")).Emoji],
|
|
17
|
+
["math", async () => {
|
|
18
|
+
const { MathBlock, MathInline, MathEditing } = await import("@domternal/extension-math")
|
|
19
|
+
return [MathBlock, MathInline, MathEditing]
|
|
20
|
+
}],
|
|
21
|
+
["code_block", async () => (await import("@domternal/extension-code-block-lowlight")).CodeBlockLowlight],
|
|
22
|
+
["toc", async () => {
|
|
23
|
+
const { UniqueID } = await import("@domternal/core")
|
|
24
|
+
const { TableOfContents, TableOfContentsBlock } = await import("@domternal/extension-toc")
|
|
25
|
+
return [UniqueID, TableOfContents, TableOfContentsBlock]
|
|
26
|
+
}],
|
|
27
|
+
// The Notion-style block UX (drag handle, block context menu, slash command, smart
|
|
28
|
+
// paste, keyboard reorder) ships as five separate extensions rather than one bundle.
|
|
29
|
+
["block_controls", async () => {
|
|
30
|
+
const { BlockHandle, BlockContextMenu, KeyboardReorder, SlashCommand, SmartPaste, FloatingMenu } =
|
|
31
|
+
await import("@domternal/extension-block-controls")
|
|
32
|
+
return [BlockHandle, BlockContextMenu, KeyboardReorder, SlashCommand, SmartPaste, FloatingMenu]
|
|
33
|
+
}],
|
|
34
|
+
["details", async () => {
|
|
35
|
+
const { Details, DetailsSummary, DetailsContent } = await import("@domternal/extension-details")
|
|
36
|
+
return [Details, DetailsSummary, DetailsContent]
|
|
37
|
+
}],
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
export function registerExtension(name, loader) {
|
|
41
|
+
registry.set(name, loader)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function resolveExtensions(names, options = {}) {
|
|
45
|
+
const resolved = await Promise.all(names.map(async (name) => {
|
|
46
|
+
const loader = registry.get(name)
|
|
47
|
+
if (!loader) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[domternal] Unknown extension "${name}". Registered: ${Array.from(registry.keys()).join(", ")}. ` +
|
|
50
|
+
"Register custom extensions with registerExtension() from \"domternal/extensions\"."
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
return loader(options)
|
|
54
|
+
}))
|
|
55
|
+
return resolved.flat()
|
|
56
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Entry point for `import "domternal"`. The install generator pins this whole directory
|
|
2
|
+
// (`pin_all_from ..., under: "domternal"`), so sibling files import each other by bare
|
|
3
|
+
// specifier (e.g. "domternal/extensions") rather than by relative path — Propshaft/Sprockets
|
|
4
|
+
// fingerprint each file independently, and a relative "./extensions.js" import would resolve
|
|
5
|
+
// against the browser's literal (un-fingerprinted) URL and 404.
|
|
6
|
+
export { DomternalEditorElement } from "domternal/element"
|
|
7
|
+
export { registerExtension } from "domternal/extensions"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/try"
|
|
4
|
+
require "action_view/helpers/tags/placeholderable"
|
|
5
|
+
|
|
6
|
+
module Domternal
|
|
7
|
+
module TagHelper
|
|
8
|
+
cattr_accessor(:id, instance_accessor: false) { 0 }
|
|
9
|
+
|
|
10
|
+
# Renders a hidden field plus a <domternal-editor> custom element that mounts the
|
|
11
|
+
# editor and keeps the hidden field's value in sync on every change — the same shape
|
|
12
|
+
# as ActionText's rich_textarea_tag/<trix-editor> pair.
|
|
13
|
+
#
|
|
14
|
+
# #### Options
|
|
15
|
+
# * `:extensions` - symbolic extension names (see Rails::Domternal::ExtensionRegistry).
|
|
16
|
+
# Defaults to the attribute's `has_rich_domternal` declaration, or
|
|
17
|
+
# `Rails::Domternal.configuration.default_extensions`.
|
|
18
|
+
# * `:toolbar` - :full, :minimal, :notion, or false. Same defaulting as `:extensions`.
|
|
19
|
+
# * `[:data][:direct_upload_url]` - defaults to `rails_direct_uploads_url`.
|
|
20
|
+
# * `[:data][:blob_url_template]` - defaults to `rails_service_blob_url(":signed_id", ":filename")`.
|
|
21
|
+
def domternal_area_tag(name, value = nil, options = {}, &block)
|
|
22
|
+
value = capture(&block) if value.nil? && block_given?
|
|
23
|
+
options = options.symbolize_keys
|
|
24
|
+
form = options.delete(:form)
|
|
25
|
+
extensions = Array(options.delete(:extensions) || Rails::Domternal.configuration.default_extensions)
|
|
26
|
+
toolbar = options.key?(:toolbar) ? options.delete(:toolbar) : Rails::Domternal.configuration.toolbar
|
|
27
|
+
|
|
28
|
+
options[:input] ||= "domternal_input_#{Domternal::TagHelper.id += 1}"
|
|
29
|
+
options[:class] ||= "domternal-content"
|
|
30
|
+
|
|
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")
|
|
34
|
+
options[:data][:extensions] ||= extensions.join(" ")
|
|
35
|
+
options[:data][:toolbar] ||= toolbar.to_s
|
|
36
|
+
options[:data][:theme] ||= Rails::Domternal.configuration.theme.to_s
|
|
37
|
+
options[:data][:max_file_size] ||= Rails::Domternal.configuration.max_file_size
|
|
38
|
+
options[:data][:allowed_mime_types] ||= Rails::Domternal.configuration.allowed_mime_types.join(" ")
|
|
39
|
+
|
|
40
|
+
editor_tag = content_tag("domternal-editor", "", options)
|
|
41
|
+
input_tag = hidden_field_tag(name, value.to_s, id: options[:input], form: form)
|
|
42
|
+
|
|
43
|
+
input_tag + editor_tag
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def domternal_content(value)
|
|
47
|
+
content_tag(:div, value.to_s.html_safe, class: "domternal-content") # rubocop:disable Rails/OutputSafety -- sanitized by Domternal::RichText before persistence
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module ActionView::Helpers
|
|
53
|
+
class Tags::Domternal < Tags::Base
|
|
54
|
+
include Tags::Placeholderable
|
|
55
|
+
|
|
56
|
+
delegate :dom_id, to: ActionView::RecordIdentifier
|
|
57
|
+
|
|
58
|
+
def render(&block)
|
|
59
|
+
options = @options.stringify_keys
|
|
60
|
+
add_default_name_and_field(options)
|
|
61
|
+
options["input"] ||= dom_id(object, [options["id"], :domternal_input].compact.join("_")) if object
|
|
62
|
+
|
|
63
|
+
attribute_options = object.class.try(:domternal_attributes)&.dig(@method_name.to_sym) || {}
|
|
64
|
+
options["extensions"] ||= attribute_options[:extensions]
|
|
65
|
+
options["toolbar"] = attribute_options[:toolbar] unless options.key?("toolbar")
|
|
66
|
+
|
|
67
|
+
html_tag = @template_object.domternal_area_tag(
|
|
68
|
+
options.delete("name"), options.fetch("value") { value }, options.except("value"), &block
|
|
69
|
+
)
|
|
70
|
+
error_wrapping(html_tag)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
module FormHelper
|
|
75
|
+
# Returns a domternal_area_tag for the given object/method — see FormBuilder#domternal_area.
|
|
76
|
+
def domternal_area(object_name, method, options = {}, &block)
|
|
77
|
+
Tags::Domternal.new(object_name, method, self, options).render(&block)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class FormBuilder
|
|
82
|
+
# Wraps ActionView::Helpers::FormHelper#domternal_area for form builders:
|
|
83
|
+
#
|
|
84
|
+
# <%= form_with model: @post do |f| %>
|
|
85
|
+
# <%= f.domternal_area :body %>
|
|
86
|
+
# <% end %>
|
|
87
|
+
def domternal_area(method, options = {}, &block)
|
|
88
|
+
@template.domternal_area(@object_name, method, objectify_options(options), &block)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Domternal
|
|
4
|
+
# Holds the HTML body + Active Storage embeds for a single has_rich_domternal attribute.
|
|
5
|
+
# Body is stored as the same sanitized HTML the Domternal editor renders/re-parses
|
|
6
|
+
# (Domternal's `Editor`/`DomternalEditor` accept HTML directly as `content`), so no
|
|
7
|
+
# separate JSON<->HTML conversion step is needed on the Ruby side.
|
|
8
|
+
class RichText < ActiveRecord::Base
|
|
9
|
+
self.table_name = "domternal_rich_texts"
|
|
10
|
+
|
|
11
|
+
belongs_to :record, polymorphic: true, touch: true
|
|
12
|
+
has_many_attached :embeds
|
|
13
|
+
|
|
14
|
+
before_validation :sanitize_body
|
|
15
|
+
before_validation :attach_embedded_blobs
|
|
16
|
+
|
|
17
|
+
delegate :blank?, :present?, :empty?, to: :to_plain_text
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
body.to_s.html_safe # rubocop:disable Rails/OutputSafety -- sanitized in #sanitize_body before persistence
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_plain_text
|
|
24
|
+
body.to_s.gsub(/<[^>]*>/, " ").gsub(/\s+/, " ").strip
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def sanitize_body
|
|
30
|
+
return if body.blank?
|
|
31
|
+
|
|
32
|
+
config = Rails::Domternal.configuration
|
|
33
|
+
sanitizer_class = Rails::HTML::Sanitizer.best_supported_vendor.safe_list_sanitizer
|
|
34
|
+
self.body = sanitizer_class.new.sanitize(
|
|
35
|
+
body,
|
|
36
|
+
tags: config.sanitizer_allowed_tags,
|
|
37
|
+
attributes: config.sanitizer_allowed_attributes
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Blob URLs embedded by the Domternal image extension's upload handler look like
|
|
42
|
+
# /rails/active_storage/blobs/redirect/:signed_id/:filename (see
|
|
43
|
+
# app/assets/javascripts/domternal/direct_upload.js). Extracting the signed_id lets us
|
|
44
|
+
# attach the blob for lifecycle management (dependent: :destroy, backup/admin visibility)
|
|
45
|
+
# without needing a custom attachment tag/model the way ActionText does.
|
|
46
|
+
def attach_embedded_blobs
|
|
47
|
+
return if body.blank?
|
|
48
|
+
|
|
49
|
+
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) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class CreateDomternalTables < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
|
4
|
+
|
|
5
|
+
create_table :domternal_rich_texts, id: primary_key_type do |t|
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.text :body
|
|
8
|
+
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
|
|
12
|
+
t.index [:record_type, :record_id, :name], name: "index_domternal_rich_texts_uniqueness", unique: true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def primary_and_foreign_key_types
|
|
19
|
+
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]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Domternal
|
|
6
|
+
module Generators
|
|
7
|
+
# rails g domternal:install
|
|
8
|
+
#
|
|
9
|
+
# Detects whether the host app uses importmap-rails or a Node/Bun bundler
|
|
10
|
+
# (jsbundling-rails, vite_rails, ...) and wires up the JS accordingly, mirroring
|
|
11
|
+
# ActionText's install generator (lib/generators/action_text/install/install_generator.rb).
|
|
12
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
source_paths << File.expand_path("../../../../app/assets/javascripts", __dir__)
|
|
15
|
+
|
|
16
|
+
# @domternal/vanilla and every @domternal/extension-* package declare @domternal/core
|
|
17
|
+
# (and, transitively, @domternal/pm — the ProseMirror re-export used for plugin
|
|
18
|
+
# identity) as peer dependencies. esm.sh only emits real `import` statements for a
|
|
19
|
+
# peer (rather than inlining its own private copy) when it's passed via `?external=`,
|
|
20
|
+
# so every consumer below must externalize the same set — otherwise two different
|
|
21
|
+
# copies of ProseMirror's Plugin/PluginKey classes end up on the page and construction
|
|
22
|
+
# fails with "Adding different instances of a keyed plugin". @domternal/pm's own
|
|
23
|
+
# subpaths (state, view, model, ...) are covered by a single prefix pin instead of
|
|
24
|
+
# being listed one by one.
|
|
25
|
+
CORE_PACKAGE = "@domternal/core"
|
|
26
|
+
PM_PACKAGE = "@domternal/pm"
|
|
27
|
+
|
|
28
|
+
NPM_PACKAGES = {
|
|
29
|
+
CORE_PACKAGE => nil,
|
|
30
|
+
"@domternal/vanilla" => [CORE_PACKAGE],
|
|
31
|
+
"@domternal/extension-image" => [CORE_PACKAGE, PM_PACKAGE]
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
OPTIONAL_NPM_PACKAGES = %w[
|
|
35
|
+
@domternal/extension-table @domternal/extension-mention @domternal/extension-markdown
|
|
36
|
+
@domternal/extension-emoji @domternal/extension-math @domternal/extension-code-block-lowlight
|
|
37
|
+
@domternal/extension-toc @domternal/extension-block-controls @domternal/extension-details
|
|
38
|
+
].index_with { [CORE_PACKAGE, PM_PACKAGE] }.freeze
|
|
39
|
+
|
|
40
|
+
class_option :npm_version, type: :string, default: "latest",
|
|
41
|
+
desc: "Version/dist-tag used when pinning @domternal/* packages"
|
|
42
|
+
|
|
43
|
+
class_option :stylesheet, type: :string, default: nil, enum: %w[css tailwind],
|
|
44
|
+
desc: "Format for the generated content typography stylesheet. Defaults to " \
|
|
45
|
+
"autodetecting app/assets/tailwind/application.css (tailwindcss-rails)."
|
|
46
|
+
|
|
47
|
+
def create_initializer
|
|
48
|
+
template "initializer.rb", "config/initializers/domternal.rb"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create_stylesheet
|
|
52
|
+
template "domternal.css.erb", "app/assets/stylesheets/domternal.css"
|
|
53
|
+
say <<~INSTRUCTIONS, :green
|
|
54
|
+
Add `<%= stylesheet_link_tag "domternal" %>` to your layout (next to your
|
|
55
|
+
application stylesheet tag) to load the Domternal theme and editor styles.
|
|
56
|
+
INSTRUCTIONS
|
|
57
|
+
|
|
58
|
+
create_content_stylesheet
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_migrations
|
|
62
|
+
rails_command "railties:install:migrations FROM=active_storage,domternal", inline: true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def wire_up_javascript
|
|
66
|
+
if using_importmap?
|
|
67
|
+
wire_up_importmap
|
|
68
|
+
elsif using_jsbundling?
|
|
69
|
+
wire_up_jsbundling
|
|
70
|
+
else
|
|
71
|
+
say <<~INSTRUCTIONS, :yellow
|
|
72
|
+
Could not detect importmap-rails or a package.json — add the Domternal JS
|
|
73
|
+
yourself. See #{__dir__}/../../../../README.md for both approaches.
|
|
74
|
+
INSTRUCTIONS
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def show_readme
|
|
79
|
+
say <<~INSTRUCTIONS, :green
|
|
80
|
+
|
|
81
|
+
Domternal is installed. Next steps:
|
|
82
|
+
|
|
83
|
+
1. bin/rails db:migrate
|
|
84
|
+
2. In a model: has_rich_domternal :body
|
|
85
|
+
3. In a form: <%= form.domternal_area :body %>
|
|
86
|
+
4. To render: <%= domternal_content @post.body %>
|
|
87
|
+
INSTRUCTIONS
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def npm_version
|
|
93
|
+
options["npm_version"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def using_importmap?
|
|
97
|
+
Pathname(destination_root).join("config/importmap.rb").exist?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def using_jsbundling?
|
|
101
|
+
Pathname(destination_root).join("package.json").exist?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def tailwind_stylesheet?
|
|
105
|
+
options["stylesheet"] == "tailwind" ||
|
|
106
|
+
(options["stylesheet"].nil? && tailwind_entry_path.exist?)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def tailwind_entry_path
|
|
110
|
+
Pathname(destination_root).join("app/assets/tailwind/application.css")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def create_content_stylesheet
|
|
114
|
+
if tailwind_stylesheet?
|
|
115
|
+
template "domternal_content.tailwind.css.erb", "app/assets/tailwind/domternal_content.css"
|
|
116
|
+
import_line = %(@import "./domternal_content.css";\n)
|
|
117
|
+
if tailwind_entry_path.exist?
|
|
118
|
+
append_to_file tailwind_entry_path.to_s, import_line unless
|
|
119
|
+
tailwind_entry_path.read.include?(import_line.strip)
|
|
120
|
+
say "Generated app/assets/tailwind/domternal_content.css (Tailwind @apply) and " \
|
|
121
|
+
"imported it into application.css — it's built into your existing tailwind.css, " \
|
|
122
|
+
"no extra <link> tag needed.", :green
|
|
123
|
+
else
|
|
124
|
+
say 'Generated app/assets/tailwind/domternal_content.css — add ' \
|
|
125
|
+
'`@import "./domternal_content.css";` to your Tailwind entrypoint yourself ' \
|
|
126
|
+
"(no app/assets/tailwind/application.css found to wire it into automatically).", :yellow
|
|
127
|
+
end
|
|
128
|
+
else
|
|
129
|
+
template "domternal_content.css.erb", "app/assets/stylesheets/domternal_content.css"
|
|
130
|
+
say 'Add `<%= stylesheet_link_tag "domternal_content" %>` to your layout to load ' \
|
|
131
|
+
"the generated content typography.", :green
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def using_bun?
|
|
136
|
+
using_jsbundling? && Pathname(destination_root).join("bun.config.js").exist?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def wire_up_importmap
|
|
140
|
+
importmap_path = Pathname(destination_root).join("config/importmap.rb")
|
|
141
|
+
|
|
142
|
+
pins = +"\n# rails-domternal\n"
|
|
143
|
+
pins << %(pin_all_from Rails::Domternal::Engine.root.join("app/assets/javascripts/domternal"), under: "domternal", preload: true\n)
|
|
144
|
+
pins << %(pin "@rails/activestorage", to: "activestorage.esm.js", preload: true\n) unless
|
|
145
|
+
importmap_path.read.include?('"@rails/activestorage"')
|
|
146
|
+
|
|
147
|
+
pins << %(pin "#{PM_PACKAGE}/", to: "https://esm.sh/#{PM_PACKAGE}@#{npm_version}/"\n)
|
|
148
|
+
|
|
149
|
+
all_packages.each do |package, externals|
|
|
150
|
+
url = "https://esm.sh/#{package}@#{npm_version}"
|
|
151
|
+
url += "?external=#{externals.join(",")}" if externals.present?
|
|
152
|
+
preload = package == CORE_PACKAGE
|
|
153
|
+
pins << %(pin "#{package}", to: "#{url}"#{preload ? "" : ", preload: false"}\n)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
append_to_file importmap_path.to_s, pins
|
|
157
|
+
say "Pinned domternal + @domternal/* packages (via esm.sh) in config/importmap.rb", :green
|
|
158
|
+
|
|
159
|
+
application_js = Pathname(destination_root).join("app/javascript/application.js")
|
|
160
|
+
if application_js.exist?
|
|
161
|
+
append_to_file application_js.to_s, %(import "domternal"\n) unless
|
|
162
|
+
application_js.read.include?('import "domternal"')
|
|
163
|
+
else
|
|
164
|
+
say 'Add `import "domternal"` to your JS entrypoint.', :yellow
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def all_packages
|
|
169
|
+
NPM_PACKAGES.merge(OPTIONAL_NPM_PACKAGES)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def wire_up_jsbundling
|
|
173
|
+
say "Installing JavaScript dependencies", :green
|
|
174
|
+
packages = (all_packages.keys + ["@domternal/theme"]).map { |p| "#{p}@#{npm_version}" }
|
|
175
|
+
if using_bun?
|
|
176
|
+
run "bun add #{packages.join(" ")}"
|
|
177
|
+
else
|
|
178
|
+
run "npm install #{packages.join(" ")}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
directory "domternal", "app/javascript/domternal"
|
|
182
|
+
|
|
183
|
+
destination = Pathname(destination_root)
|
|
184
|
+
application_js = destination.join("app/javascript/application.js")
|
|
185
|
+
if application_js.exist?
|
|
186
|
+
append_to_file application_js.to_s, %(\nimport "domternal/index"\n)
|
|
187
|
+
else
|
|
188
|
+
say "Add `import \"domternal/index\"` to your JS entrypoint.", :yellow
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Domternal theme — loaded from jsDelivr by default so it works regardless of asset
|
|
3
|
+
* strategy. If you'd rather bundle it (cssbundling-rails/npm), remove this @import,
|
|
4
|
+
* `npm add @domternal/theme`, and `@import "@domternal/theme/scss"` from your own
|
|
5
|
+
* stylesheet entrypoint instead.
|
|
6
|
+
*/
|
|
7
|
+
@import url("https://cdn.jsdelivr.net/npm/@domternal/theme@<%= npm_version %>/dist/domternal-theme.css");
|
|
8
|
+
|
|
9
|
+
domternal-editor {
|
|
10
|
+
display: block;
|
|
11
|
+
border: 1px solid var(--dm-border-color, #d0d5dd);
|
|
12
|
+
border-radius: 6px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
domternal-editor .domternal-surface {
|
|
16
|
+
padding: 0.75rem 1rem;
|
|
17
|
+
min-height: 12rem;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
domternal-editor .domternal-toolbar {
|
|
21
|
+
border-bottom: 1px solid var(--dm-border-color, #d0d5dd);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.domternal-content img {
|
|
25
|
+
max-width: 100%;
|
|
26
|
+
height: auto;
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Baseline typography for Domternal content — applies to both the live editor surface
|
|
3
|
+
* (`<domternal-editor>`) and read-only output (`domternal_content`, class="domternal-content").
|
|
4
|
+
* Generated once by `domternal:install` and not touched again — edit freely.
|
|
5
|
+
*/
|
|
6
|
+
.domternal-content h1, domternal-editor h1 { font-size: 1.875rem; font-weight: 700; line-height: 1.2; margin: 1.5rem 0 0.75rem; }
|
|
7
|
+
.domternal-content h2, domternal-editor h2 { font-size: 1.5rem; font-weight: 700; line-height: 1.25; margin: 1.25rem 0 0.5rem; }
|
|
8
|
+
.domternal-content h3, domternal-editor h3 { font-size: 1.25rem; font-weight: 600; line-height: 1.3; margin: 1rem 0 0.5rem; }
|
|
9
|
+
.domternal-content h4, domternal-editor h4,
|
|
10
|
+
.domternal-content h5, domternal-editor h5,
|
|
11
|
+
.domternal-content h6, domternal-editor h6 { font-size: 1.0625rem; font-weight: 600; margin: 1rem 0 0.5rem; }
|
|
12
|
+
|
|
13
|
+
.domternal-content p, domternal-editor p { margin: 0.75rem 0; }
|
|
14
|
+
|
|
15
|
+
.domternal-content ul, domternal-editor ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
|
|
16
|
+
.domternal-content ol, domternal-editor ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
|
|
17
|
+
.domternal-content li, domternal-editor li { margin: 0.25rem 0; }
|
|
18
|
+
.domternal-content li > ul, domternal-editor li > ul,
|
|
19
|
+
.domternal-content li > ol, domternal-editor li > ol { margin: 0.25rem 0; }
|
|
20
|
+
|
|
21
|
+
.domternal-content blockquote, domternal-editor blockquote {
|
|
22
|
+
border-left: 3px solid #d0d5dd;
|
|
23
|
+
padding-left: 1rem;
|
|
24
|
+
margin: 0.75rem 0;
|
|
25
|
+
color: #475467;
|
|
26
|
+
font-style: italic;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.domternal-content code, domternal-editor code {
|
|
30
|
+
background: #f2f4f7;
|
|
31
|
+
border-radius: 4px;
|
|
32
|
+
padding: 0.125rem 0.375rem;
|
|
33
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
34
|
+
font-size: 0.875em;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.domternal-content pre, domternal-editor pre {
|
|
38
|
+
background: #101828;
|
|
39
|
+
color: #f2f4f7;
|
|
40
|
+
border-radius: 6px;
|
|
41
|
+
padding: 0.75rem 1rem;
|
|
42
|
+
overflow-x: auto;
|
|
43
|
+
margin: 0.75rem 0;
|
|
44
|
+
}
|
|
45
|
+
.domternal-content pre code, domternal-editor pre code { background: transparent; padding: 0; }
|
|
46
|
+
|
|
47
|
+
.domternal-content a, domternal-editor a { color: #175cd3; text-decoration: underline; }
|
|
48
|
+
|
|
49
|
+
.domternal-content img, domternal-editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
|
50
|
+
|
|
51
|
+
.domternal-content table, domternal-editor table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
|
|
52
|
+
.domternal-content th, domternal-editor th,
|
|
53
|
+
.domternal-content td, domternal-editor td { border: 1px solid #d0d5dd; padding: 0.375rem 0.625rem; }
|
|
54
|
+
.domternal-content th, domternal-editor th { font-weight: 600; background: #f9fafb; }
|
|
55
|
+
|
|
56
|
+
.domternal-content hr, domternal-editor hr { border: none; border-top: 1px solid #d0d5dd; margin: 1.5rem 0; }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Baseline typography for Domternal content — applies to both the live editor surface
|
|
3
|
+
* (`<domternal-editor>`) and read-only output (`domternal_content`, class="domternal-content").
|
|
4
|
+
* Generated once by `domternal:install` and not touched again — edit freely, e.g. swap the
|
|
5
|
+
* @apply utilities below for your own design tokens.
|
|
6
|
+
*/
|
|
7
|
+
.domternal-content, domternal-editor {
|
|
8
|
+
h1 { @apply text-3xl font-bold leading-tight mt-6 mb-3; }
|
|
9
|
+
h2 { @apply text-2xl font-bold leading-snug mt-5 mb-2; }
|
|
10
|
+
h3 { @apply text-xl font-semibold mt-4 mb-2; }
|
|
11
|
+
h4, h5, h6 { @apply text-lg font-semibold mt-4 mb-2; }
|
|
12
|
+
|
|
13
|
+
p { @apply my-3; }
|
|
14
|
+
|
|
15
|
+
ul { @apply list-disc pl-6 my-3; }
|
|
16
|
+
ol { @apply list-decimal pl-6 my-3; }
|
|
17
|
+
li { @apply my-1; }
|
|
18
|
+
li > ul, li > ol { @apply my-1; }
|
|
19
|
+
|
|
20
|
+
blockquote { @apply border-l-4 border-gray-300 pl-4 my-3 text-gray-600 italic; }
|
|
21
|
+
|
|
22
|
+
code { @apply bg-gray-100 rounded px-1.5 py-0.5 font-mono text-sm; }
|
|
23
|
+
|
|
24
|
+
pre { @apply bg-gray-900 text-gray-100 rounded-md px-4 py-3 my-3 overflow-x-auto; }
|
|
25
|
+
pre code { @apply bg-transparent p-0; }
|
|
26
|
+
|
|
27
|
+
a { @apply text-blue-700 underline; }
|
|
28
|
+
|
|
29
|
+
img { @apply max-w-full h-auto rounded; }
|
|
30
|
+
|
|
31
|
+
table { @apply border-collapse w-full my-3; }
|
|
32
|
+
th, td { @apply border border-gray-300 px-2.5 py-1.5; }
|
|
33
|
+
th { @apply font-semibold bg-gray-50; }
|
|
34
|
+
|
|
35
|
+
hr { @apply border-t border-gray-300 my-6; }
|
|
36
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Configuration for rails-domternal, a Domternal (https://github.com/domternal/domternal)
|
|
2
|
+
# editor integration shaped like ActionText. See Rails::Domternal::Configuration for the
|
|
3
|
+
# full list of options.
|
|
4
|
+
Rails::Domternal.configure do |config|
|
|
5
|
+
# Symbolic extension keys resolved client-side by app/assets/javascripts/domternal/extensions.js
|
|
6
|
+
# and validated server-side by Rails::Domternal::ExtensionRegistry. Override per-attribute with
|
|
7
|
+
# `has_rich_domternal :body, extensions: %i[starter_kit image table]`.
|
|
8
|
+
# config.default_extensions = %i[starter_kit image]
|
|
9
|
+
|
|
10
|
+
# :full, :minimal, :notion, or false — overridable per has_rich_domternal/domternal_area call.
|
|
11
|
+
# config.toolbar = :full
|
|
12
|
+
|
|
13
|
+
# :light, :dark, or :auto
|
|
14
|
+
# config.theme = :light
|
|
15
|
+
|
|
16
|
+
# config.allowed_mime_types = %w[image/png image/jpeg image/gif image/webp image/svg+xml image/avif]
|
|
17
|
+
# config.max_file_size = 10.megabytes
|
|
18
|
+
|
|
19
|
+
# Tags/attributes let through the safelist sanitizer applied to incoming bodies before they're
|
|
20
|
+
# persisted. Widen this if you enable extensions (e.g. extension-table, extension-details) that
|
|
21
|
+
# introduce markup not covered by the defaults.
|
|
22
|
+
# config.sanitizer_allowed_tags += %w[]
|
|
23
|
+
# config.sanitizer_allowed_attributes += %w[]
|
|
24
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Rails
|
|
6
|
+
module Domternal
|
|
7
|
+
module Attribute
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
# Declares a dependent Domternal::RichText association, mirroring ActionText's
|
|
12
|
+
# `has_rich_text`. Example:
|
|
13
|
+
#
|
|
14
|
+
# class Post < ApplicationRecord
|
|
15
|
+
# has_rich_domternal :body, extensions: %i[starter_kit image table], toolbar: :full
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# post.body = "<p>Hello <strong>World</strong>!</p>"
|
|
19
|
+
# post.body.to_s
|
|
20
|
+
#
|
|
21
|
+
# `extensions:` and `toolbar:` are per-attribute overrides of
|
|
22
|
+
# Rails::Domternal.configuration.default_extensions/toolbar; both are rendered into
|
|
23
|
+
# the `domternal_area` form tag's data attributes so the client knows what to load.
|
|
24
|
+
def has_rich_domternal(name, extensions: nil, toolbar: nil)
|
|
25
|
+
extensions&.each { |extension| Rails::Domternal.extension_registry.fetch!(extension) }
|
|
26
|
+
|
|
27
|
+
class_eval <<-CODE, __FILE__, __LINE__ + 1
|
|
28
|
+
def #{name}
|
|
29
|
+
rich_domternal_#{name} || build_rich_domternal_#{name}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def #{name}?
|
|
33
|
+
rich_domternal_#{name}.present?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def #{name}=(body)
|
|
37
|
+
self.#{name}.body = body
|
|
38
|
+
end
|
|
39
|
+
CODE
|
|
40
|
+
|
|
41
|
+
has_one :"rich_domternal_#{name}", -> { where(name: name) },
|
|
42
|
+
class_name: "Domternal::RichText", as: :record, inverse_of: :record,
|
|
43
|
+
autosave: true, dependent: :destroy
|
|
44
|
+
|
|
45
|
+
scope :"with_rich_domternal_#{name}", -> { includes("rich_domternal_#{name}") }
|
|
46
|
+
|
|
47
|
+
domternal_attributes[name.to_sym] = {
|
|
48
|
+
extensions: extensions || Rails::Domternal.configuration.default_extensions,
|
|
49
|
+
toolbar: toolbar.nil? ? Rails::Domternal.configuration.toolbar : toolbar
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Eager loads all dependent Domternal::RichText models in bulk.
|
|
54
|
+
def with_all_rich_domternal
|
|
55
|
+
includes(domternal_association_names)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def domternal_association_names
|
|
59
|
+
reflect_on_all_associations(:has_one).collect(&:name).select { |n| n.start_with?("rich_domternal_") }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Options passed to `has_rich_domternal`, keyed by attribute name. Read by
|
|
63
|
+
# Domternal::TagHelper to fill in the `domternal_area` tag's config without the
|
|
64
|
+
# caller having to repeat them.
|
|
65
|
+
def domternal_attributes
|
|
66
|
+
@domternal_attributes ||= {}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Domternal
|
|
5
|
+
class Configuration
|
|
6
|
+
# Symbolic extension keys resolved client-side by app/assets/javascripts/domternal/extensions.js
|
|
7
|
+
attr_accessor :default_extensions
|
|
8
|
+
|
|
9
|
+
# :full, :minimal, :notion, or false. :notion swaps the fixed toolbar for a
|
|
10
|
+
# selection bubble menu + a "+" floating menu, matching the block handle/slash
|
|
11
|
+
# command UI the :block_controls extension adds — pair the two.
|
|
12
|
+
attr_accessor :toolbar
|
|
13
|
+
|
|
14
|
+
# :light, :dark, or :auto
|
|
15
|
+
attr_accessor :theme
|
|
16
|
+
|
|
17
|
+
# :importmap, :jsbundling, or :cdn — how the install generator wires up JS. Informational
|
|
18
|
+
# at runtime; only the generator branches on it.
|
|
19
|
+
attr_accessor :asset_strategy
|
|
20
|
+
|
|
21
|
+
# npm dist-tag/version used when pinning packages from a CDN (importmap/:cdn strategies).
|
|
22
|
+
attr_accessor :npm_version
|
|
23
|
+
|
|
24
|
+
attr_accessor :allowed_mime_types
|
|
25
|
+
attr_accessor :max_file_size
|
|
26
|
+
|
|
27
|
+
# HTML tags/attributes allowed through the safelist sanitizer applied to incoming bodies.
|
|
28
|
+
attr_accessor :sanitizer_allowed_tags
|
|
29
|
+
attr_accessor :sanitizer_allowed_attributes
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@default_extensions = %i[starter_kit image]
|
|
33
|
+
@toolbar = :full
|
|
34
|
+
@theme = :light
|
|
35
|
+
@asset_strategy = :importmap
|
|
36
|
+
@npm_version = "latest"
|
|
37
|
+
@allowed_mime_types = %w[image/png image/jpeg image/gif image/webp image/svg+xml image/avif]
|
|
38
|
+
@max_file_size = 10.megabytes
|
|
39
|
+
|
|
40
|
+
@sanitizer_allowed_tags = %w[
|
|
41
|
+
p br div span
|
|
42
|
+
h1 h2 h3 h4 h5 h6
|
|
43
|
+
strong em b i u s del ins mark sub sup code pre
|
|
44
|
+
ul ol li blockquote
|
|
45
|
+
a img figure figcaption
|
|
46
|
+
table thead tbody tr th td
|
|
47
|
+
hr details summary
|
|
48
|
+
]
|
|
49
|
+
@sanitizer_allowed_attributes = %w[
|
|
50
|
+
href src alt title width height loading crossorigin
|
|
51
|
+
colspan rowspan align style class data-type data-id data-label
|
|
52
|
+
]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Rails
|
|
6
|
+
module Domternal
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace Rails::Domternal
|
|
9
|
+
engine_name "domternal"
|
|
10
|
+
|
|
11
|
+
config.domternal = Rails::Domternal.configuration
|
|
12
|
+
|
|
13
|
+
initializer "domternal.assets" do |app|
|
|
14
|
+
if app.config.respond_to?(:assets)
|
|
15
|
+
app.config.assets.paths << root.join("app/assets/javascripts")
|
|
16
|
+
app.config.assets.precompile += %w[domternal/index.js]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
initializer "domternal.active_storage" do
|
|
21
|
+
require "active_storage/engine"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
initializer "domternal.helper" do
|
|
25
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
26
|
+
helper ::Domternal::TagHelper
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
initializer "domternal.attribute" do
|
|
31
|
+
ActiveSupport.on_load(:active_record) do
|
|
32
|
+
include Rails::Domternal::Attribute
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Domternal
|
|
5
|
+
# Ruby-side mirror of app/assets/javascripts/domternal/extensions.js — kept in sync
|
|
6
|
+
# so `has_rich_domternal extensions: [...]` can fail fast on typos instead of only
|
|
7
|
+
# erroring in the browser console.
|
|
8
|
+
class ExtensionRegistry
|
|
9
|
+
UnknownExtensionError = Class.new(StandardError)
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@known = %i[
|
|
13
|
+
starter_kit image table mention markdown emoji math
|
|
14
|
+
code_block toc block_controls details
|
|
15
|
+
].to_set
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register(name)
|
|
19
|
+
@known << name.to_sym
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def known?(name)
|
|
23
|
+
@known.include?(name.to_sym)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fetch!(name)
|
|
27
|
+
unless known?(name)
|
|
28
|
+
raise UnknownExtensionError,
|
|
29
|
+
"Unknown Domternal extension #{name.inspect}. Known extensions: #{@known.to_a.sort.inspect}. " \
|
|
30
|
+
"Register custom ones with Rails::Domternal.extension_registry.register(:my_extension) " \
|
|
31
|
+
"and app/assets/javascripts/domternal/extensions.js's registerExtension()."
|
|
32
|
+
end
|
|
33
|
+
name.to_sym
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "domternal/version"
|
|
4
|
+
require_relative "domternal/configuration"
|
|
5
|
+
require_relative "domternal/extension_registry"
|
|
6
|
+
require_relative "domternal/attribute"
|
|
7
|
+
|
|
8
|
+
module Rails
|
|
9
|
+
module Domternal
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def configuration
|
|
14
|
+
@configuration ||= Configuration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield configuration
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def extension_registry
|
|
22
|
+
@extension_registry ||= ExtensionRegistry.new
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
require_relative "domternal/engine" if defined?(::Rails::Engine)
|
metadata
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails-domternal
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Stefan
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
description: Wraps the Domternal (ProseMirror-based) editor with a has_rich_domternal
|
|
27
|
+
model attribute, a domternal_area form helper, and Active Storage-backed image uploads
|
|
28
|
+
so it can be adopted with the same shape as ActionText.
|
|
29
|
+
email:
|
|
30
|
+
- stefan@getrestful.ch
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- LICENSE.txt
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- app/assets/javascripts/domternal/direct_upload.js
|
|
39
|
+
- app/assets/javascripts/domternal/element.js
|
|
40
|
+
- app/assets/javascripts/domternal/extensions.js
|
|
41
|
+
- app/assets/javascripts/domternal/index.js
|
|
42
|
+
- app/helpers/domternal/tag_helper.rb
|
|
43
|
+
- app/models/domternal/rich_text.rb
|
|
44
|
+
- db/migrate/20260101000000_create_domternal_tables.rb
|
|
45
|
+
- lib/generators/domternal/install/install_generator.rb
|
|
46
|
+
- lib/generators/domternal/install/templates/domternal.css.erb
|
|
47
|
+
- lib/generators/domternal/install/templates/domternal_content.css.erb
|
|
48
|
+
- lib/generators/domternal/install/templates/domternal_content.tailwind.css.erb
|
|
49
|
+
- lib/generators/domternal/install/templates/initializer.rb
|
|
50
|
+
- lib/rails-domternal.rb
|
|
51
|
+
- lib/rails/domternal.rb
|
|
52
|
+
- lib/rails/domternal/attribute.rb
|
|
53
|
+
- lib/rails/domternal/configuration.rb
|
|
54
|
+
- lib/rails/domternal/engine.rb
|
|
55
|
+
- lib/rails/domternal/extension_registry.rb
|
|
56
|
+
- lib/rails/domternal/version.rb
|
|
57
|
+
- sig/rails/domternal.rbs
|
|
58
|
+
homepage: https://github.com/getrestful/rails-domternal
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata:
|
|
62
|
+
homepage_uri: https://github.com/getrestful/rails-domternal
|
|
63
|
+
source_code_uri: https://github.com/getrestful/rails-domternal
|
|
64
|
+
rdoc_options: []
|
|
65
|
+
require_paths:
|
|
66
|
+
- lib
|
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: 3.2.0
|
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '0'
|
|
77
|
+
requirements: []
|
|
78
|
+
rubygems_version: 3.7.2
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Domternal rich text editor with notion style UI and integration for Rails,
|
|
81
|
+
as a drop-in ActionText replacement.
|
|
82
|
+
test_files: []
|