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 +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +143 -0
- data/app/assets/javascripts/smeditor.js +249 -0
- data/app/controllers/smeditor/uploads_controller.rb +12 -0
- data/config/routes.rb +7 -0
- data/lib/generators/smeditor/install_generator.rb +53 -0
- data/lib/generators/smeditor/templates/initializer.rb +22 -0
- data/lib/smeditor/rails/config.rb +59 -0
- data/lib/smeditor/rails/engine.rb +56 -0
- data/lib/smeditor/rails/form_helper.rb +64 -0
- data/lib/smeditor/rails/renderer.rb +28 -0
- data/lib/smeditor/rails/sanitizer.rb +204 -0
- data/lib/smeditor/rails/uploads.rb +98 -0
- data/lib/smeditor/rails/version.rb +7 -0
- data/lib/smeditor.rb +29 -0
- metadata +111 -0
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
|
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: []
|