smeditor 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2fa2dffff9ec8a5d1b502cbcba1c44420d778f0e5377db886812922fb9fd543c
4
+ data.tar.gz: f2892ee2d34a3b726d7e726acb5bca9de6a1973f18c8b06257dd425945f193a3
5
+ SHA512:
6
+ metadata.gz: 9a0923f3f4a6d7c51826a5e93e0853d3b9e452cbb46aa2a7027392a9fdfd9af6ed604a03e3224e6e4f86f10742f02b8dc0989441461890bd03842b388bcfa7a5
7
+ data.tar.gz: 8b4a6d29c7f033da64c91127d1a3a2a5b00f19607b60b10294972abff6137a7940a5464313231193cff23c83e516a971a3568a860e01c4555f0edfba915b6559
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog — smeditor
2
+
3
+ All notable changes to the Rails adapter are recorded here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the gem
5
+ follows [RubyGems versioning](https://guides.rubygems.org/patterns/).
6
+
7
+ Cross-cutting product changes live in the repository root
8
+ [CHANGELOG.md](../../CHANGELOG.md).
9
+
10
+ ## [Unreleased]
11
+
12
+ ### Fixed
13
+
14
+ - The boot script no longer imports `@smeditor/theme-default/index.css`, a
15
+ subpath the theme package did not export. It now imports the package root.
16
+ - `EditorContent` receives the editor instance explicitly, so the editor
17
+ actually mounts. It reads the instance from its prop, not from context.
18
+ - The default toolbar renders its buttons. `Toolbar` renders only the
19
+ children it is given, so the previous empty element produced an empty bar.
20
+ - `EditorProvider` receives `version`, and the editor is wrapped in a
21
+ `.smeditor` element so the default theme applies.
22
+ - Booting also runs when the bundle is evaluated after `DOMContentLoaded`
23
+ has already fired, and on `turbo:frame-load`.
24
+
25
+ ### Added
26
+
27
+ - `smeditor:install` copies the boot script into `app/javascript/smeditor.js`
28
+ so the host app's bundler resolves the `@smeditor/*` imports.
29
+ Pass `--skip-javascript` to opt out.
30
+ - `config.max_upload_size` (10 MB default) and `config.allowed_upload_types`
31
+ are enforced by the upload endpoint.
32
+ - `LICENSE` and `CHANGELOG.md` ship in the gem; the internal `PLAN.md` no
33
+ longer does.
34
+ - Gemspec metadata: homepage, contact, source, changelog and issue URLs.
35
+
36
+ ---
37
+
38
+ ## 0.1.0 — 2026-07-06
39
+
40
+ First release of the Rails adapter: form helper, server-side renderer,
41
+ sanitizer, and the optional ActiveStorage upload endpoint.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SMEditor contributors
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # smeditor
2
+
3
+ Rails integration for the [SMEditor](https://github.com/sCruze/smeditor)
4
+ rich-text editor.
5
+
6
+ This gem is a **thin adapter**. The editor is the upstream npm
7
+ packages (`@smeditor/react`, `@smeditor/starter-kit`, …); the gem
8
+ adds a form helper, a safe server-side renderer, and an optional
9
+ upload endpoint. It holds no schema, no commands and no rendering
10
+ pipeline of its own.
11
+
12
+ ## Requirements
13
+
14
+ - Ruby >= 3.0, Rails >= 7.0
15
+ - A JavaScript bundler in the host app — `jsbundling-rails` (esbuild,
16
+ rollup, webpack, bun) or Vite. The boot script imports the npm
17
+ packages by bare specifier, which Sprockets and importmap cannot
18
+ resolve on their own.
19
+
20
+ ## Install
21
+
22
+ ```ruby
23
+ # Gemfile
24
+ gem "smeditor"
25
+ ```
26
+
27
+ ```bash
28
+ bundle install
29
+ bin/rails generate smeditor:install
30
+ ```
31
+
32
+ The generator writes `config/initializers/smeditor.rb` and copies the
33
+ boot script to `app/javascript/smeditor.js`. Then install the editor
34
+ itself and import the script:
35
+
36
+ ```bash
37
+ npm install @smeditor/react @smeditor/starter-kit \
38
+ @smeditor/full-kit @smeditor/theme-default react react-dom
39
+ ```
40
+
41
+ ```js
42
+ // app/javascript/application.js
43
+ import "./smeditor";
44
+ ```
45
+
46
+ Mount the engine if you want the upload endpoint:
47
+
48
+ ```ruby
49
+ # config/routes.rb
50
+ mount SMEditor::Rails::Engine => "/smeditor"
51
+ ```
52
+
53
+ ## Namespace
54
+
55
+ Константа гема — `SMEditor` (не `Smeditor`). Каталоги называются `smeditor/`,
56
+ поэтому движок сам регистрирует переопределение в обоих автозагрузчиках
57
+ Zeitwerk (`smeditor` → `SMEditor`) до сканирования autoload-путей, а генератор
58
+ объявляет namespace явно. Настроек в приложении не требуется, и глобальные
59
+ inflections ActiveSupport не трогаются.
60
+
61
+ ## Configure
62
+
63
+ ```ruby
64
+ # config/initializers/smeditor.rb
65
+ SMEditor.configure do |c|
66
+ c.uploads = :active_storage # or :none (default)
67
+ c.sanitize_output = true
68
+ c.default_kit = "starter" # or "full"
69
+ c.upload_path = "/smeditor/uploads"
70
+ c.allowed_tags = %w[p h1 h2 h3 strong em a img]
71
+ c.max_upload_size = 10 * 1024 * 1024
72
+ c.allowed_upload_types = %w[image/png image/jpeg image/webp]
73
+ end
74
+ ```
75
+
76
+ ## Use
77
+
78
+ The form helper mounts an editor and persists its HTML through a
79
+ hidden field — a normal form submit saves the content:
80
+
81
+ ```erb
82
+ <%= form_with model: @article do |form| %>
83
+ <%= form.smeditor_editor :content %>
84
+ <%= form.submit %>
85
+ <% end %>
86
+ ```
87
+
88
+ Options: `:kit` (`"starter"` / `"full"`), `:placeholder`, `:class`,
89
+ `:upload_url`.
90
+
91
+ Display saved content on a public page — always sanitized:
92
+
93
+ ```erb
94
+ <%= smeditor_render(@article.content) %>
95
+ ```
96
+
97
+ ## How it works
98
+
99
+ `smeditor_editor` renders a hidden field (seeded with the stored
100
+ HTML) and an empty mount point. The boot script finds each mount,
101
+ attaches a real editor from the npm packages, and writes
102
+ `editor.getHTML()` back into the hidden field on every change. The
103
+ model attribute is a plain HTML string — SMEditor's transport format —
104
+ so no special column type or serializer is needed.
105
+
106
+ Booting runs on `DOMContentLoaded`, on `turbo:load` and on
107
+ `turbo:frame-load`; each mount is booted at most once.
108
+
109
+ ## Rendering and trust
110
+
111
+ Stored editor HTML is treated as untrusted input. `smeditor_render`
112
+ passes it through `SMEditor::Rails::Sanitizer`, which applies the tag
113
+ allow-list, strips unsafe URLs and `style` declarations, and adds
114
+ `rel="noopener noreferrer"` to `target="_blank"` links.
115
+
116
+ Setting `config.sanitize_output = false` marks stored content
117
+ `html_safe` without sanitizing it. Only do that when the content is
118
+ already trusted upstream.
119
+
120
+ ## Uploads
121
+
122
+ With `config.uploads = :active_storage`, the engine exposes
123
+ `POST /smeditor/uploads`. It stores image files via ActiveStorage and
124
+ responds with `{ src, alt, title }` for the editor to embed.
125
+
126
+ The endpoint is **unauthenticated by default** and only enforces the
127
+ content-type allow-list and `max_upload_size`. Subclass
128
+ `SMEditor::UploadsController` and add your own authorization before
129
+ exposing it to the public internet.
130
+
131
+ With `:none`, no endpoint is mounted — wire your own.
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ cd gems/smeditor
137
+ bundle install
138
+ bundle exec rspec
139
+ ```
140
+
141
+ ## License
142
+
143
+ MIT © SMEditor contributors
@@ -0,0 +1,249 @@
1
+ /**
2
+ * smeditor.js — the gem's boot script.
3
+ *
4
+ * The form helper renders, for each editor field:
5
+ *
6
+ * <div class="smeditor-field">
7
+ * <input type="hidden" data-smeditor-input value="<saved html>">
8
+ * <div class="smeditor-mount" data-smeditor data-smeditor-kit="…">
9
+ * </div>
10
+ *
11
+ * This script finds every mount, attaches a real editor seeded from
12
+ * the hidden field, and writes editor.getHTML() back into that field
13
+ * on every change — so a plain form submit persists the content.
14
+ *
15
+ * The editor itself is the upstream npm packages. The gem ships no
16
+ * editor core; it only wires Rails forms, assets and optional uploads.
17
+ *
18
+ * `bin/rails generate smeditor:install` copies this file into
19
+ * app/javascript/smeditor.js so the host app's bundler (esbuild, rollup,
20
+ * webpack, bun) resolves the @smeditor/* imports from package.json.
21
+ */
22
+
23
+ import { createRoot } from "react-dom/client";
24
+ import { createElement, Fragment, useMemo } from "react";
25
+ import {
26
+ useEditor,
27
+ EditorProvider,
28
+ EditorContent,
29
+ Toolbar,
30
+ ToolbarDivider,
31
+ UndoButton,
32
+ RedoButton,
33
+ BlockTypeDropdown,
34
+ ListsDropdown,
35
+ BoldButton,
36
+ ItalicButton,
37
+ UnderlineButton,
38
+ StrikeButton,
39
+ ClearFormattingButton,
40
+ LinkButton,
41
+ AlignDropdown,
42
+ BlockquoteButton,
43
+ CodeBlockButton,
44
+ HorizontalRuleButton,
45
+ ImageButton,
46
+ ImageToolbar,
47
+ TableButton,
48
+ TableToolbar,
49
+ ImageResizer,
50
+ TableColumnResizer,
51
+ TableCellSelection,
52
+ HighlightButton,
53
+ TextColorButton,
54
+ BackgroundColorButton,
55
+ FontFamilyDropdown,
56
+ FontSizeDropdown,
57
+ LineHeightDropdown,
58
+ } from "@smeditor/react";
59
+ import { StarterKit, ImageExtension } from "@smeditor/starter-kit";
60
+ import { FullKit } from "@smeditor/full-kit";
61
+ import "@smeditor/theme-default";
62
+
63
+ function csrfToken() {
64
+ const meta = document.querySelector('meta[name="csrf-token"]');
65
+ return meta ? meta.getAttribute("content") : null;
66
+ }
67
+
68
+ /**
69
+ * Posts the file to the Rails upload endpoint. The controller answers
70
+ * with { src, alt, title }, which is exactly the ImageAttrs shape the
71
+ * image extension accepts.
72
+ */
73
+ function uploadHandler(uploadUrl) {
74
+ if (!uploadUrl) return undefined;
75
+
76
+ return async (file) => {
77
+ const body = new FormData();
78
+ body.append("file", file);
79
+
80
+ const headers = {};
81
+ const token = csrfToken();
82
+ if (token) headers["X-CSRF-Token"] = token;
83
+
84
+ const response = await fetch(uploadUrl, {
85
+ method: "POST",
86
+ headers,
87
+ body,
88
+ credentials: "same-origin",
89
+ });
90
+
91
+ if (!response.ok) {
92
+ throw new Error(`SMEditor upload failed with ${response.status}`);
93
+ }
94
+
95
+ return response.json();
96
+ };
97
+ }
98
+
99
+ function kitExtensions(kit, uploadUrl) {
100
+ const base = kit === "full" ? FullKit.extensions : StarterKit.extensions;
101
+ const upload = uploadHandler(uploadUrl);
102
+
103
+ if (!upload) return base;
104
+
105
+ return base.map((extension) =>
106
+ extension.name === "image"
107
+ ? ImageExtension.configure({ upload })
108
+ : extension,
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Default toolbar. `Toolbar` renders whatever children it is given, so
114
+ * the buttons have to be listed explicitly — an empty `<Toolbar />`
115
+ * produces an empty bar.
116
+ */
117
+ function DefaultToolbar({ kit }) {
118
+ const common = [
119
+ createElement(UndoButton, { key: "undo" }),
120
+ createElement(RedoButton, { key: "redo" }),
121
+ createElement(ToolbarDivider, { key: "d1" }),
122
+
123
+ createElement(BlockTypeDropdown, { key: "block" }),
124
+ createElement(ListsDropdown, { key: "lists" }),
125
+ createElement(ToolbarDivider, { key: "d2" }),
126
+
127
+ createElement(BoldButton, { key: "bold" }),
128
+ createElement(ItalicButton, { key: "italic" }),
129
+ createElement(UnderlineButton, { key: "underline" }),
130
+ createElement(StrikeButton, { key: "strike" }),
131
+ createElement(ClearFormattingButton, { key: "clear" }),
132
+ createElement(LinkButton, { key: "link" }),
133
+ createElement(ToolbarDivider, { key: "d3" }),
134
+
135
+ createElement(AlignDropdown, { key: "align" }),
136
+ createElement(ToolbarDivider, { key: "d4" }),
137
+
138
+ createElement(BlockquoteButton, { key: "quote" }),
139
+ createElement(CodeBlockButton, { key: "code" }),
140
+ createElement(HorizontalRuleButton, { key: "hr" }),
141
+ createElement(ImageButton, { key: "image" }),
142
+ createElement(TableButton, { key: "table" }),
143
+ ];
144
+
145
+ const full =
146
+ kit === "full"
147
+ ? [
148
+ createElement(ToolbarDivider, { key: "d5" }),
149
+ createElement(HighlightButton, { key: "highlight" }),
150
+ createElement(TextColorButton, { key: "color" }),
151
+ createElement(BackgroundColorButton, { key: "bg" }),
152
+ createElement(FontFamilyDropdown, { key: "family" }),
153
+ createElement(FontSizeDropdown, { key: "size" }),
154
+ createElement(LineHeightDropdown, { key: "line" }),
155
+ ]
156
+ : [];
157
+
158
+ return createElement(
159
+ Toolbar,
160
+ { ariaLabel: "SMEditor editor toolbar" },
161
+ ...common,
162
+ ...full,
163
+ );
164
+ }
165
+
166
+ /** One mounted editor field. */
167
+ function SMEditorField({ mount, input }) {
168
+ const kit = mount.dataset.smeditorKit === "full" ? "full" : "starter";
169
+ const uploadUrl = mount.dataset.smeditorUploadUrl || "";
170
+ const placeholder = mount.dataset.smeditorPlaceholder || undefined;
171
+
172
+ const extensions = useMemo(
173
+ () => kitExtensions(kit, uploadUrl),
174
+ [kit, uploadUrl],
175
+ );
176
+
177
+ const { editor, version } = useEditor({
178
+ extensions,
179
+ content: input.value || "<p></p>",
180
+ placeholder,
181
+ onUpdate: ({ editor: instance }) => {
182
+ input.value = instance.getHTML();
183
+ },
184
+ });
185
+
186
+ // `editor` is null on the first render and `EditorContent` reads the
187
+ // instance from its prop, not from context — both have to be passed
188
+ // explicitly or the editor never mounts to the DOM node.
189
+ return createElement(
190
+ EditorProvider,
191
+ { editor, version },
192
+ createElement(
193
+ "div",
194
+ { className: "smeditor" },
195
+ createElement(DefaultToolbar, { kit }),
196
+ createElement(EditorContent, {
197
+ editor,
198
+ ariaLabel: mount.dataset.smeditorLabel || "Rich text editor",
199
+ }),
200
+ createElement(ImageToolbar, null),
201
+ createElement(TableToolbar, null),
202
+ createElement(ImageResizer, null),
203
+ createElement(TableColumnResizer, null),
204
+ createElement(TableCellSelection, null),
205
+ ),
206
+ );
207
+ }
208
+
209
+ /** Attach editors to every un-booted mount in the document. */
210
+ export function boot(root = document) {
211
+ const mounts = root.querySelectorAll("[data-smeditor]");
212
+ mounts.forEach((mount) => {
213
+ if (mount.dataset.smeditorBooted) return;
214
+
215
+ const field = mount.closest(".smeditor-field");
216
+ const input = field && field.querySelector("[data-smeditor-input]");
217
+ if (!input) return;
218
+
219
+ mount.dataset.smeditorBooted = "1";
220
+
221
+ createRoot(mount).render(
222
+ createElement(
223
+ Fragment,
224
+ null,
225
+ createElement(SMEditorField, { mount, input }),
226
+ ),
227
+ );
228
+ });
229
+ }
230
+
231
+ // Auto-boot on load and after Turbo navigations. The readyState check
232
+ // matters when the bundle is injected after the document has parsed —
233
+ // DOMContentLoaded has already fired by then and would never call boot.
234
+ if (typeof document !== "undefined") {
235
+ if (document.readyState === "loading") {
236
+ document.addEventListener("DOMContentLoaded", () => boot());
237
+ } else {
238
+ boot();
239
+ }
240
+ document.addEventListener("turbo:load", () => boot());
241
+ document.addEventListener("turbo:frame-load", (event) => {
242
+ boot(event.target || document);
243
+ });
244
+ }
245
+
246
+ // Re-exported so a host app can build a custom integration instead of auto-boot.
247
+ export * from "@smeditor/react";
248
+ export { StarterKit, ImageExtension } from "@smeditor/starter-kit";
249
+ export { FullKit } from "@smeditor/full-kit";
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMEditor
4
+ # Default upload controller. A host app can use this as-is (the
5
+ # engine routes POST /smeditor/uploads to it) or subclass it to add
6
+ # authentication.
7
+ class UploadsController < ::ActionController::Base
8
+ include SMEditor::Rails::Uploads
9
+
10
+ protect_from_forgery with: :null_session
11
+ end
12
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mounted by SMEditor::Rails::Engine. Provides the single upload
4
+ # endpoint; only reachable when config.uploads == :active_storage.
5
+ SMEditor::Rails::Engine.routes.draw do
6
+ post "/uploads", to: "uploads#create", as: :uploads
7
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module SMEditor
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ # Thor вывел бы из имени класса namespace "sm_editor:install".
9
+ # Задаём явно, чтобы генератор звался `rails g smeditor:install`.
10
+ namespace "smeditor:install"
11
+
12
+ desc "Install SMEditor Rails configuration and the editor boot script"
13
+
14
+ class_option :skip_javascript,
15
+ type: :boolean,
16
+ default: false,
17
+ desc: "Do not copy smeditor.js into app/javascript"
18
+
19
+ # Two source roots: the generator templates, and the packaged boot
20
+ # script. Copying the shipped asset rather than a duplicate template
21
+ # keeps a single source of truth for the JavaScript.
22
+ def self.source_paths
23
+ [
24
+ File.expand_path("templates", __dir__),
25
+ File.expand_path("../../../app/assets/javascripts", __dir__),
26
+ ]
27
+ end
28
+
29
+ def copy_initializer
30
+ template "initializer.rb", "config/initializers/smeditor.rb"
31
+ end
32
+
33
+ # The boot script imports @smeditor/* by bare specifier, so it has to
34
+ # be compiled by the host app's JavaScript bundler. Sprockets and
35
+ # importmap cannot resolve those imports; jsbundling-rails (esbuild,
36
+ # rollup, webpack) or Vite can.
37
+ def copy_boot_script
38
+ copy_file "smeditor.js", "app/javascript/smeditor.js" unless options[:skip_javascript]
39
+ end
40
+
41
+ def show_next_steps
42
+ say ""
43
+ say "SMEditor installed. Next:"
44
+ say " 1. npm install @smeditor/react @smeditor/starter-kit \\"
45
+ say " @smeditor/full-kit @smeditor/theme-default react react-dom"
46
+ say " 2. Add `import \"./smeditor\"` to app/javascript/application.js"
47
+ say " 3. For uploads: set config.uploads = :active_storage and mount"
48
+ say " `mount SMEditor::Rails::Engine => \"/smeditor\"` in config/routes.rb"
49
+ say ""
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ SMEditor.configure do |config|
4
+ # Use :active_storage after mounting the engine and installing ActiveStorage.
5
+ config.uploads = :none
6
+
7
+ # Used by the JavaScript adapter when uploads are enabled.
8
+ config.upload_path = "/smeditor/uploads"
9
+
10
+ # "starter" gives the MVP toolbar; "full" enables the larger extension set.
11
+ config.default_kit = "starter"
12
+
13
+ # Stored editor HTML is sanitized before public rendering.
14
+ config.sanitize_output = true
15
+
16
+ # Upload limits. The endpoint is unauthenticated unless you subclass
17
+ # SMEditor::UploadsController, so keep these tight.
18
+ config.max_upload_size = 10 * 1024 * 1024
19
+ config.allowed_upload_types = %w[
20
+ image/png image/jpeg image/gif image/webp image/avif image/bmp
21
+ ]
22
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMEditor
4
+ # Gem-wide configuration, set in an initializer:
5
+ #
6
+ # SMEditor.configure do |c|
7
+ # c.uploads = :active_storage
8
+ # c.allowed_tags = %w[p h1 h2 strong em a]
9
+ # end
10
+ #
11
+ class Config
12
+ # :none (default) or :active_storage — whether the gem mounts an
13
+ # upload endpoint.
14
+ attr_accessor :uploads
15
+ # When true, smeditor_render sanitizes content before display.
16
+ attr_accessor :sanitize_output
17
+ # Tag allow-list for the server-side renderer.
18
+ attr_accessor :allowed_tags
19
+ # Which bundle the form helper boots: "starter" or "full".
20
+ attr_accessor :default_kit
21
+ # Upload endpoint used by the JavaScript adapter when uploads are enabled.
22
+ attr_accessor :upload_path
23
+ # Hard ceiling on an uploaded file, in bytes. The endpoint is reachable
24
+ # by anyone who can reach the host app unless the controller is
25
+ # subclassed with authentication, so it refuses anything larger.
26
+ attr_accessor :max_upload_size
27
+ # Content types the upload endpoint accepts. SVG is excluded on
28
+ # purpose — it can carry script and is served from the app's origin.
29
+ attr_accessor :allowed_upload_types
30
+
31
+ def initialize
32
+ @uploads = :none
33
+ @sanitize_output = true
34
+ @allowed_tags = %w[
35
+ p br h1 h2 h3 h4 h5 h6 strong em u s code pre blockquote
36
+ ul ol li a img figure figcaption hr span
37
+ table thead tbody tr td th
38
+ ]
39
+ @default_kit = "starter"
40
+ @upload_path = "/smeditor/uploads"
41
+ @max_upload_size = 10 * 1024 * 1024
42
+ @allowed_upload_types = %w[
43
+ image/png image/jpeg image/gif image/webp image/avif image/bmp
44
+ ]
45
+ end
46
+ end
47
+
48
+ class << self
49
+ # Yields the config for editing and exposes the same object to callers.
50
+ def configure
51
+ yield(config) if block_given?
52
+ config
53
+ end
54
+
55
+ def config
56
+ @config ||= Config.new
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `rails/engine` не подтягивает core-расширения ActiveSupport сам. Вне
4
+ # приложения (например, в rspec) гем грузится первым, и Rails::Initializable
5
+ # падает на delegate_missing_to. `require "rails"` ставит их на место.
6
+ require "rails"
7
+ require "rails/engine"
8
+
9
+ module SMEditor
10
+ module Rails
11
+ # Rails::Engine subclass — the gem's plumbing.
12
+ #
13
+ # It does three things and nothing more (per PLAN.md the gem stays
14
+ # a thin adapter):
15
+ # 1. Isolates the SMEditor namespace so routes / controllers
16
+ # don't collide with the host app.
17
+ # 2. Mixes the view helpers into ActionView.
18
+ # 3. Adds smeditor_editor to the form builder.
19
+ #
20
+ # The upload route lives in config/routes.rb and is mounted by the
21
+ # host with `mount SMEditor::Rails::Engine => "/smeditor"`.
22
+ class Engine < ::Rails::Engine
23
+ isolate_namespace SMEditor
24
+
25
+ # `isolate_namespace` выводит "sm_editor" из имени константы. Закрепляем
26
+ # "smeditor", чтобы прокси маршрутов у смонтированного движка назывался
27
+ # `smeditor.uploads_path`.
28
+ engine_name "smeditor"
29
+ railtie_name "smeditor"
30
+
31
+ # Zeitwerk по имени каталога `smeditor/` вывел бы константу `Smeditor`.
32
+ # Оба автозагрузчика узнают про `SMEditor` до сканирования autoload-путей —
33
+ # иначе `SMEditor::UploadsController` не найдётся.
34
+ initializer "smeditor.inflections", before: :set_autoload_paths do
35
+ ::Rails.autoloaders.each do |autoloader|
36
+ autoloader.inflector.inflect("smeditor" => "SMEditor")
37
+ end
38
+ end
39
+
40
+ initializer "smeditor.view_helpers" do
41
+ ActiveSupport.on_load(:action_view) do
42
+ include SMEditor::Rails::FormHelper
43
+ include SMEditor::Rails::RenderHelper
44
+ end
45
+ end
46
+
47
+ initializer "smeditor.form_builder" do
48
+ ActiveSupport.on_load(:action_view) do
49
+ ActionView::Helpers::FormBuilder.include(
50
+ SMEditor::Rails::FormBuilderExtension
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMEditor
4
+ module Rails
5
+ # View helper that mounts a SMEditor editor inside a Rails form.
6
+ #
7
+ # <%= form.smeditor_editor :content %> # via FormBuilder
8
+ # <%= smeditor_editor(form, :content) %> # standalone
9
+ #
10
+ # Emits a hidden field carrying the document HTML plus an empty
11
+ # mount point. The boot script (smeditor.js) finds the mount,
12
+ # attaches a real editor seeded from the hidden field, and writes
13
+ # editor.getHTML() back into that field on every change — so a
14
+ # normal form submit persists the content with no extra wiring.
15
+ module FormHelper
16
+ # form - a Rails FormBuilder
17
+ # method - the model attribute (stored as an HTML string)
18
+ # options - :kit ("starter" | "full"), :class, :placeholder, :upload_url
19
+ def smeditor_editor(form, method, options = {})
20
+ object = form.object
21
+ current = object.respond_to?(method) ? object.public_send(method) : nil
22
+ kit = (options[:kit] || SMEditor.config.default_kit).to_s
23
+ upload_url = options.fetch(:upload_url, smeditor_upload_url)
24
+
25
+ hidden = form.hidden_field(
26
+ method,
27
+ value: current.to_s,
28
+ data: { smeditor_input: true },
29
+ )
30
+
31
+ mount = content_tag(
32
+ :div,
33
+ "",
34
+ class: "smeditor-mount",
35
+ data: {
36
+ smeditor: true,
37
+ smeditor_kit: kit,
38
+ smeditor_placeholder: options[:placeholder],
39
+ smeditor_upload_url: upload_url,
40
+ },
41
+ )
42
+
43
+ wrapper_class = ["smeditor-field", options[:class]].compact.join(" ")
44
+ content_tag(:div, safe_join([hidden, mount]), class: wrapper_class)
45
+ end
46
+
47
+ private
48
+
49
+ def smeditor_upload_url
50
+ if SMEditor.config.uploads == :active_storage
51
+ SMEditor.config.upload_path
52
+ end
53
+ end
54
+ end
55
+
56
+ # Mixed into ActionView::Helpers::FormBuilder so `form.smeditor_editor`
57
+ # works alongside the standalone helper.
58
+ module FormBuilderExtension
59
+ def smeditor_editor(method, options = {})
60
+ @template.smeditor_editor(self, method, options)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMEditor
4
+ module Rails
5
+ # View helper for displaying saved content on a public page.
6
+ #
7
+ # <%= smeditor_render(@article.content) %>
8
+ #
9
+ # The stored value is editor HTML. It is sanitized (unless
10
+ # config.sanitize_output is false) and wrapped in a container that
11
+ # the default theme styles.
12
+ module RenderHelper
13
+ # content - stored editor HTML
14
+ # allowed_tags - override the configured tag allow-list
15
+ # html_class - extra CSS class on the wrapper
16
+ def smeditor_render(content, allowed_tags: nil, html_class: nil)
17
+ html = content.to_s
18
+ if SMEditor.config.sanitize_output
19
+ html = SMEditor::Rails::Sanitizer.sanitize(
20
+ html, allowed_tags: allowed_tags
21
+ )
22
+ end
23
+ wrapper = ["smeditor-content", html_class].compact.join(" ")
24
+ content_tag(:div, html.html_safe, class: wrapper)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module SMEditor
6
+ module Rails
7
+ # Wraps Rails' HTML sanitizer with a SMEditor-shaped allow-list.
8
+ #
9
+ # Stored editor HTML is treated as untrusted user input, even when
10
+ # the JavaScript serializer produced it. Public display should pass
11
+ # through this adapter first.
12
+ module Sanitizer
13
+ module_function
14
+
15
+ # Attributes kept on surviving tags. `style` is included because
16
+ # SMEditor serializes color, background, highlight, text stroke,
17
+ # font size, font family and alignment as constrained inline CSS.
18
+ ALLOWED_ATTRS = %w[
19
+ href src alt title class style target rel
20
+ width height data-align data-mention-id data-mention-label
21
+ colspan rowspan
22
+ ].freeze
23
+
24
+ COLOR_PATTERN = /\A(?:#[0-9a-fA-F]{3,8}|rgba?\([^;<>"']+\)|hsla?\([^;<>"']+\)|[a-zA-Z]+)\z/
25
+ FONT_FAMILY_PATTERN = /\A[a-zA-Z0-9 ,"'-]{1,200}\z/
26
+ LENGTH_PATTERN = /\A(\d+(?:\.\d+)?)(px|pt|em|rem|%)?\z/
27
+ LINE_HEIGHT_PATTERN = /\A(\d+(?:\.\d+)?)(px|em|rem|%)?\z/
28
+ TEXT_STROKE_WIDTH_PATTERN = /\A(\d+(?:\.\d+)?)px\z/i
29
+ SAFE_TARGETS = %w[_blank _self _parent _top].freeze
30
+ RASTER_DATA_URL_PATTERN = /\Adata:image\/(?:png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+\/=\s]+\z/i
31
+
32
+ # Produces a sanitized HTML String (not html_safe — the caller
33
+ # decides where it is safe to mark it so).
34
+ def sanitize(html, allowed_tags: nil)
35
+ tags = allowed_tags || SMEditor.config.allowed_tags
36
+ sanitized = sanitizer_class.new.sanitize(
37
+ html.to_s,
38
+ tags: tags,
39
+ attributes: ALLOWED_ATTRS,
40
+ ).to_s
41
+ harden_blank_targets(
42
+ sanitize_url_attributes(
43
+ sanitize_inline_styles(sanitized),
44
+ ),
45
+ )
46
+ end
47
+
48
+ # Rails 7.1+ exposes Rails::HTML5::SafeListSanitizer; older
49
+ # versions use Rails::HTML::SafeListSanitizer.
50
+ def sanitizer_class
51
+ if defined?(::Rails::HTML5::SafeListSanitizer)
52
+ ::Rails::HTML5::SafeListSanitizer
53
+ else
54
+ ::Rails::HTML::SafeListSanitizer
55
+ end
56
+ end
57
+
58
+ def sanitize_inline_styles(html)
59
+ html.gsub(/\sstyle=(['"])(.*?)\1/mi) do
60
+ quote = Regexp.last_match(1)
61
+ style = CGI.unescapeHTML(Regexp.last_match(2).to_s)
62
+ declarations = style.split(";").filter_map do |declaration|
63
+ sanitize_style_declaration(declaration)
64
+ end
65
+ if declarations.empty?
66
+ ""
67
+ else
68
+ %( style=#{quote}#{CGI.escapeHTML(declarations.join("; "))}#{quote})
69
+ end
70
+ end
71
+ end
72
+
73
+ def sanitize_url_attributes(html)
74
+ html.gsub(/\s(href|src)=(['"])(.*?)\2/mi) do
75
+ name = Regexp.last_match(1).downcase
76
+ quote = Regexp.last_match(2)
77
+ value = CGI.unescapeHTML(Regexp.last_match(3).to_s)
78
+ safe_url?(name, value) ? %( #{name}=#{quote}#{CGI.escapeHTML(value.strip)}#{quote}) : ""
79
+ end.gsub(/\starget=(['"])(.*?)\1/mi) do
80
+ quote = Regexp.last_match(1)
81
+ value = CGI.unescapeHTML(Regexp.last_match(2).to_s).strip
82
+ SAFE_TARGETS.include?(value) ? %( target=#{quote}#{value}#{quote}) : ""
83
+ end
84
+ end
85
+
86
+ def harden_blank_targets(html)
87
+ html.gsub(/<a\b([^>]*)>/mi) do
88
+ attrs = Regexp.last_match(1).to_s
89
+ if attrs.match?(/\starget=(['"])_blank\1/i)
90
+ if attrs.match?(/\srel=/i)
91
+ %(<a#{attrs}>)
92
+ else
93
+ %(<a#{attrs} rel="noopener noreferrer">)
94
+ end
95
+ else
96
+ %(<a#{attrs}>)
97
+ end
98
+ end
99
+ end
100
+
101
+ def sanitize_style_declaration(declaration)
102
+ match = declaration.match(/\A\s*([a-z-]+)\s*:\s*(.+?)\s*\z/i)
103
+ if match
104
+ name = match[1].downcase
105
+ value = sanitize_style_value(name, match[2])
106
+ value ? "#{name}: #{value}" : nil
107
+ end
108
+ end
109
+
110
+ def sanitize_style_value(name, value)
111
+ case name
112
+ when "text-align"
113
+ sanitize_text_align(value)
114
+ when "color", "background-color", "background"
115
+ sanitize_color(value)
116
+ when "font-family"
117
+ sanitize_font_family(value)
118
+ when "font-size"
119
+ sanitize_font_size(value)
120
+ when "line-height"
121
+ sanitize_line_height(value)
122
+ when "-webkit-text-stroke", "text-stroke"
123
+ sanitize_text_stroke(value)
124
+ when "-webkit-text-stroke-color", "text-stroke-color"
125
+ sanitize_color(value)
126
+ when "-webkit-text-stroke-width", "text-stroke-width"
127
+ sanitize_text_stroke_width(value)
128
+ end
129
+ end
130
+
131
+ def sanitize_text_align(value)
132
+ align = value.to_s.strip.downcase
133
+ %w[left center right justify].include?(align) ? align : nil
134
+ end
135
+
136
+ def sanitize_color(value)
137
+ color = value.to_s.strip
138
+ color.match?(COLOR_PATTERN) && !color.match?(/[;:"'<>\\]/) ? color : nil
139
+ end
140
+
141
+ def sanitize_font_family(value)
142
+ family = value.to_s.strip
143
+ family.match?(FONT_FAMILY_PATTERN) ? family : nil
144
+ end
145
+
146
+ def sanitize_font_size(value)
147
+ size = value.to_s.strip
148
+ match = size.match(LENGTH_PATTERN)
149
+ if match && match[1].to_f.positive? && match[1].to_f <= 400
150
+ "#{match[1]}#{match[2] || 'px'}"
151
+ end
152
+ end
153
+
154
+ def sanitize_line_height(value)
155
+ height = value.to_s.strip
156
+ match = height.match(LINE_HEIGHT_PATTERN)
157
+ if match && match[1].to_f.positive? && match[1].to_f <= 10
158
+ "#{match[1]}#{match[2]}"
159
+ end
160
+ end
161
+
162
+ def sanitize_text_stroke(value)
163
+ match = value.to_s.strip.match(/\A([^\s]+)\s+(.+)\z/)
164
+ if match
165
+ width = sanitize_text_stroke_width(match[1])
166
+ color = sanitize_color(match[2])
167
+ width && color ? "#{width} #{color}" : nil
168
+ end
169
+ end
170
+
171
+ def sanitize_text_stroke_width(value)
172
+ width = value.to_s.strip
173
+ match = width.match(TEXT_STROKE_WIDTH_PATTERN)
174
+ if match && match[1].to_f >= 0 && match[1].to_f <= 8
175
+ "#{match[1]}px"
176
+ end
177
+ end
178
+
179
+ def safe_url?(attribute, value)
180
+ url = value.to_s.strip
181
+ if url.empty? || url.match?(/[\u0000-\u001f\u007f\s<>"']/)
182
+ false
183
+ elsif attribute == "src" && url.match?(/\Adata:/i)
184
+ url.match?(RASTER_DATA_URL_PATTERN)
185
+ elsif url.start_with?("#")
186
+ attribute == "href"
187
+ elsif url.start_with?("//")
188
+ false
189
+ elsif url.start_with?("/", "?")
190
+ true
191
+ elsif !url.include?(":")
192
+ url.match?(/\A[.\w~-][.\w~!$&'()*+,;=:@\/-]*(?:\?[^#]*)?(?:#.*)?\z/)
193
+ else
194
+ protocol = url.split(":", 2).first.downcase
195
+ if attribute == "href"
196
+ %w[http https mailto tel].include?(protocol)
197
+ else
198
+ %w[http https].include?(protocol)
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMEditor
4
+ module Rails
5
+ # Controller concern backing the image uploader.
6
+ #
7
+ # class SMEditor::UploadsController < ApplicationController
8
+ # include SMEditor::Rails::Uploads
9
+ # end
10
+ #
11
+ # The JS side posts a `file` param; the controller stores it via
12
+ # ActiveStorage and responds with image attributes for the editor.
13
+ #
14
+ # Active only when config.uploads == :active_storage — otherwise
15
+ # the endpoint responds with 404 and the host wires its own upload.
16
+ #
17
+ # The endpoint is unauthenticated by default. It enforces a content
18
+ # type allow-list and a size ceiling, but an app with real users
19
+ # should subclass SMEditor::UploadsController and add authorization.
20
+ module Uploads
21
+ def create
22
+ error = upload_error
23
+ if error
24
+ render json: { error: error[:message] }, status: error[:status]
25
+ else
26
+ blob = ActiveStorage::Blob.create_and_upload!(
27
+ io: upload_file.to_io,
28
+ filename: upload_file.original_filename,
29
+ content_type: upload_file.content_type,
30
+ )
31
+
32
+ render json: image_payload(blob, upload_file)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # Walks every precondition once and yields the first failure, so
39
+ # `create` stays a single branch instead of a chain of guards.
40
+ def upload_error
41
+ if SMEditor.config.uploads != :active_storage
42
+ { message: "uploads disabled", status: :not_found }
43
+ elsif !active_storage_available?
44
+ { message: "active storage unavailable", status: :unprocessable_entity }
45
+ elsif upload_file.nil? || !upload_file.respond_to?(:content_type)
46
+ { message: "no file", status: :unprocessable_entity }
47
+ elsif !allowed_upload_type?(upload_file)
48
+ { message: "unsupported file type", status: :unsupported_media_type }
49
+ elsif oversized_upload?(upload_file)
50
+ { message: "file too large", status: :payload_too_large }
51
+ end
52
+ end
53
+
54
+ def upload_file
55
+ params[:file]
56
+ end
57
+
58
+ def active_storage_available?
59
+ defined?(::ActiveStorage::Blob)
60
+ end
61
+
62
+ def allowed_upload_type?(file)
63
+ type = file.content_type.to_s.split(";").first.to_s.strip.downcase
64
+ SMEditor.config.allowed_upload_types.include?(type)
65
+ end
66
+
67
+ def oversized_upload?(file)
68
+ limit = SMEditor.config.max_upload_size.to_i
69
+ limit.positive? && upload_size(file) > limit
70
+ end
71
+
72
+ # Tempfile-backed uploads know their size; anything else is measured
73
+ # from the IO so a streamed body cannot slip past the ceiling.
74
+ def upload_size(file)
75
+ if file.respond_to?(:size) && file.size
76
+ file.size
77
+ else
78
+ file.to_io.size
79
+ end
80
+ end
81
+
82
+ def image_payload(blob, file)
83
+ {
84
+ src: blob_path(blob),
85
+ alt: File.basename(file.original_filename.to_s, ".*"),
86
+ title: file.original_filename.to_s,
87
+ }
88
+ end
89
+
90
+ # only_path keeps it host-agnostic — the editor embeds a relative URL.
91
+ def blob_path(blob)
92
+ ::Rails.application.routes.url_helpers.rails_blob_path(
93
+ blob, only_path: true
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMEditor
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/smeditor.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # smeditor — thin Rails adapter for the SMEditor editor.
4
+ #
5
+ # Loading order: version and config have no Rails dependency and load
6
+ # first; the rest require Rails and are pulled in by the engine.
7
+
8
+ require "smeditor/rails/version"
9
+ require "smeditor/rails/config"
10
+
11
+ require "smeditor/rails/sanitizer"
12
+ require "smeditor/rails/form_helper"
13
+ require "smeditor/rails/renderer"
14
+ require "smeditor/rails/uploads"
15
+
16
+ # The engine require is last — it pulls in Rails. In a non-Rails
17
+ # context (e.g. unit-testing the sanitizer) the rescue keeps the gem
18
+ # loadable without the framework present.
19
+ begin
20
+ require "smeditor/rails/engine"
21
+ rescue LoadError
22
+ # Rails not available — engine features are simply unavailable.
23
+ end
24
+
25
+ module SMEditor
26
+ module Rails
27
+ class Error < StandardError; end
28
+ end
29
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: smeditor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - SMEditor contributors
8
+ bindir: bin
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.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.13'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.13'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rails-html-sanitizer
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.6'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.6'
54
+ description: |
55
+ Thin Rails adapter for SMEditor. Provides a form helper, a safe
56
+ server-side renderer, and an optional ActiveStorage-backed upload
57
+ endpoint. The editor itself is the upstream npm packages — this gem
58
+ never replaces or duplicates them.
59
+ email:
60
+ - sCruze@users.noreply.github.com
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files:
64
+ - CHANGELOG.md
65
+ - LICENSE
66
+ - README.md
67
+ files:
68
+ - CHANGELOG.md
69
+ - LICENSE
70
+ - README.md
71
+ - app/assets/javascripts/smeditor.js
72
+ - app/controllers/smeditor/uploads_controller.rb
73
+ - config/routes.rb
74
+ - lib/generators/smeditor/install_generator.rb
75
+ - lib/generators/smeditor/templates/initializer.rb
76
+ - lib/smeditor.rb
77
+ - lib/smeditor/rails/config.rb
78
+ - lib/smeditor/rails/engine.rb
79
+ - lib/smeditor/rails/form_helper.rb
80
+ - lib/smeditor/rails/renderer.rb
81
+ - lib/smeditor/rails/sanitizer.rb
82
+ - lib/smeditor/rails/uploads.rb
83
+ - lib/smeditor/rails/version.rb
84
+ homepage: https://github.com/sCruze/smeditor
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ homepage_uri: https://github.com/sCruze/smeditor
89
+ source_code_uri: https://github.com/sCruze/smeditor/tree/main/gems/smeditor
90
+ changelog_uri: https://github.com/sCruze/smeditor/blob/main/gems/smeditor/CHANGELOG.md
91
+ bug_tracker_uri: https://github.com/sCruze/smeditor/issues
92
+ documentation_uri: https://rubydoc.info/gems/smeditor/0.1.0
93
+ rubygems_mfa_required: 'true'
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '3.0'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '3.3'
107
+ requirements: []
108
+ rubygems_version: 4.0.6
109
+ specification_version: 4
110
+ summary: Rails integration for the SMEditor rich text editor.
111
+ test_files: []