prosemirror 0.0.1 → 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 +4 -4
- data/CHANGELOG.md +10 -0
- data/COOKBOOK.md +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +169 -12
- data/ROADMAP.md +211 -0
- data/lib/prosemirror/coerce.rb +17 -0
- data/lib/prosemirror/content_expression.rb +94 -0
- data/lib/prosemirror/document.rb +76 -0
- data/lib/prosemirror/html_renderer.rb +20 -0
- data/lib/prosemirror/json_parser.rb +125 -0
- data/lib/prosemirror/mark.rb +19 -0
- data/lib/prosemirror/mark_type.rb +60 -0
- data/lib/prosemirror/marks/basic/em.rb +13 -0
- data/lib/prosemirror/marks/basic/link.rb +20 -0
- data/lib/prosemirror/marks/basic/strong.rb +13 -0
- data/lib/prosemirror/marks/bold.rb +11 -0
- data/lib/prosemirror/marks/code.rb +11 -0
- data/lib/prosemirror/marks/highlight.rb +16 -0
- data/lib/prosemirror/marks/italic.rb +11 -0
- data/lib/prosemirror/marks/link.rb +21 -0
- data/lib/prosemirror/marks/strike.rb +9 -0
- data/lib/prosemirror/marks/subscript.rb +11 -0
- data/lib/prosemirror/marks/superscript.rb +11 -0
- data/lib/prosemirror/marks/underline.rb +9 -0
- data/lib/prosemirror/naming.rb +16 -0
- data/lib/prosemirror/node.rb +65 -0
- data/lib/prosemirror/node_type.rb +101 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +19 -0
- data/lib/prosemirror/nodes/basic/image.rb +22 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +19 -0
- data/lib/prosemirror/nodes/blockquote.rb +12 -0
- data/lib/prosemirror/nodes/bullet_list.rb +12 -0
- data/lib/prosemirror/nodes/code_block.rb +19 -0
- data/lib/prosemirror/nodes/doc.rb +10 -0
- data/lib/prosemirror/nodes/hard_break.rb +13 -0
- data/lib/prosemirror/nodes/heading.rb +14 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +11 -0
- data/lib/prosemirror/nodes/image.rb +21 -0
- data/lib/prosemirror/nodes/list_item.rb +11 -0
- data/lib/prosemirror/nodes/ordered_list.rb +18 -0
- data/lib/prosemirror/nodes/paragraph.rb +12 -0
- data/lib/prosemirror/nodes/table.rb +14 -0
- data/lib/prosemirror/nodes/table_cell.rb +23 -0
- data/lib/prosemirror/nodes/table_header.rb +13 -0
- data/lib/prosemirror/nodes/table_row.rb +11 -0
- data/lib/prosemirror/nodes/text.rb +10 -0
- data/lib/prosemirror/rendering.rb +70 -0
- data/lib/prosemirror/schema.rb +190 -0
- data/lib/prosemirror/text_serializer.rb +46 -0
- data/lib/prosemirror/type_definition.rb +119 -0
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +33 -2
- metadata +101 -16
- data/.rubocop.yml +0 -13
- data/Gemfile +0 -12
- data/Rakefile +0 -16
- data/prosemirror.gemspec +0 -35
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d4941e1033d3813a090427d6d94aac49e2ec017e24f5b1f4a3d8fe4f2600e29
|
|
4
|
+
data.tar.gz: 04b280abbbe4cb7d997f2c834696faba70e93cf1956a41f9516eeef62fc7ff2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1641b792c40a58d4b09f158a9ade19d48a85eebc15f7dc1039b913ab056b6c08c0bd4fc27e8809effbdee3e3a0681c1dc8dcf640ca2d2fdeae120314e341a4ce
|
|
7
|
+
data.tar.gz: cbcabc4bdcf30ad40931501c8f0ab10a84948c0d76d2e31939bcaa2783f17566a539505a897c4992e73817c454ab52e4178a1ef3a8383abf5f7e68dbf34c72ca
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.1.0] - 2026-10-23
|
|
4
|
+
|
|
5
|
+
- Render ProseMirror and Tiptap JSON to HTML (`to_html`) and text (`to_text`). In Rails, `render doc` works in ERB templates and ViewComponents, and `render doc.to_phlex` in Phlex components.
|
|
6
|
+
- `Schema.tiptap` and `Schema.prosemirror`, with `case: :camel` for camelCase type names.
|
|
7
|
+
- Custom node and mark types, as Phlex components with Literal attributes.
|
|
8
|
+
- Invalid attributes are converted, replaced by their default, or removed, and reported with `on_invalid`. `strict: true` raises instead.
|
|
9
|
+
- Attributes a node or mark type doesn't declare are kept in `unknown_attrs`: never rendered, and given back by `to_h`.
|
|
10
|
+
- URL scheme allowlist for links and images, and `resolve_url` to rewrite URLs. A URL that `resolve_url` turns into nil is removed and reported.
|
|
11
|
+
- A cookbook with recipes: skip empty paragraphs, heading anchors, render your own components, links to records with GlobalID.
|
|
12
|
+
|
|
3
13
|
## [0.0.1] - 2023-07-03
|
|
4
14
|
|
|
5
15
|
- Initial release
|
data/COOKBOOK.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Cookbook
|
|
2
|
+
|
|
3
|
+
Short recipes for common needs. Each one replaces a built-in type with a subclass, registered with `Schema#with`:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap.with(Paragraph)
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Skip empty paragraphs
|
|
10
|
+
|
|
11
|
+
Editors often leave empty paragraphs, for example after pressing Enter twice. To render nothing for them:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class Paragraph < Prosemirror::Nodes::Paragraph
|
|
15
|
+
def view_template(&)
|
|
16
|
+
p(&) unless node.content.empty?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A paragraph that only holds spaces or line breaks is not empty. To skip those too, check the text instead, keeping paragraphs with an inline image or another leaf node:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
class Paragraph < Prosemirror::Nodes::Paragraph
|
|
25
|
+
def view_template(&)
|
|
26
|
+
p(&) unless blank?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def blank?
|
|
32
|
+
node.text_content.strip.empty? && node.content.all? { |child| child.text? || child.type == :hard_break }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`to_text` still separates empty paragraphs with a blank line, as ProseMirror does. `to_h` keeps them, so the editor gets the document back unchanged.
|
|
38
|
+
|
|
39
|
+
## Heading anchors
|
|
40
|
+
|
|
41
|
+
To link to a section, give each heading an `id` made from its text, unless the document already has one:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class Heading < Prosemirror::Nodes::Heading
|
|
45
|
+
prop :id, _String?
|
|
46
|
+
|
|
47
|
+
def view_template(&)
|
|
48
|
+
tag(:"h#{@level}", id: @id || slug, &)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# "Café & co" becomes "cafe-co". In Rails, `node.text_content.parameterize` does the same.
|
|
54
|
+
def slug
|
|
55
|
+
slug = node.text_content.unicode_normalize(:nfkd).gsub(/\p{Mn}/, "").downcase.gsub(/[^a-z0-9]+/, "-")
|
|
56
|
+
slug = slug.delete_prefix("-").delete_suffix("-")
|
|
57
|
+
slug unless slug.empty?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A node doesn't see the rest of the document, so two headings with the same text get the same `id`.
|
|
63
|
+
|
|
64
|
+
## Render your own components
|
|
65
|
+
|
|
66
|
+
A node type is a Phlex component, so it can render the app's Phlex components:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class ImageBlock < Prosemirror::NodeType
|
|
70
|
+
group :block
|
|
71
|
+
|
|
72
|
+
prop :src, String
|
|
73
|
+
prop :caption, _String?
|
|
74
|
+
|
|
75
|
+
def view_template
|
|
76
|
+
render Components::Figure.new(src: safe_url(@src), caption: @caption)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
In Rails, components that use Rails helpers (`image_tag`, `url_for`…) need a view context. With [`phlex-rails`](https://github.com/yippee-fun/phlex-rails), they get one when the document is rendered with `render doc.to_phlex`, from a Phlex view or an ERB template. The same applies to a ViewComponent: `render FigureComponent.new(…)` works from a node in that case.
|
|
82
|
+
|
|
83
|
+
`<%= render doc %>` and `to_html` render the document on its own, without a view context, for example in a mailer or a JSON response. To support them, render the component with a controller instead, and output its HTML as is:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
def view_template
|
|
87
|
+
raw safe(ApplicationController.render(FigureComponent.new(src: @src, caption: @caption), layout: false))
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`raw safe(...)` outputs HTML without escaping it: only use it for HTML built by your own components, never for a value from the document.
|
|
92
|
+
|
|
93
|
+
## Links to records with GlobalID
|
|
94
|
+
|
|
95
|
+
Store links to records as GlobalIDs (`gid://app/Page/1`), so they keep working when a record's URL changes, and turn them into URLs when rendering:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
Prosemirror::Schema.tiptap(
|
|
99
|
+
resolve_url: lambda do |url|
|
|
100
|
+
next url unless url.start_with?("gid://")
|
|
101
|
+
|
|
102
|
+
record = GlobalID::Locator.locate(url)
|
|
103
|
+
Rails.application.routes.url_helpers.polymorphic_path(record) if record
|
|
104
|
+
end
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`resolve_url` runs before the allowed URL schemes are checked, so `gid://` doesn't need to be allowed. When the record doesn't exist, the link is removed, its text is kept, and the problem goes to `on_invalid`. Each link runs one query.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hans Lemuet
|
|
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
CHANGED
|
@@ -1,31 +1,188 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ProseMirror
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Render [ProseMirror](https://prosemirror.net) and [Tiptap](https://tiptap.dev) documents to HTML and text, in Ruby.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- HTML is built with [Phlex](https://www.phlex.fun) and escaped as it is written.
|
|
6
|
+
- Attributes are typed with [Literal](https://literal.fun).
|
|
7
|
+
- Link and image URLs must use an allowed scheme.
|
|
8
|
+
- No Rails dependency.
|
|
9
|
+
|
|
10
|
+
This gem replaces [prosemirror_to_html](https://github.com/etaminstudio/prosemirror_to_html) and [html_to_prosemirror](https://github.com/etaminstudio/html_to_prosemirror). HTML and Markdown import are planned: see the [roadmap](ROADMAP.md).
|
|
6
11
|
|
|
7
12
|
## Installation
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
```sh
|
|
15
|
+
bundle add prosemirror
|
|
16
|
+
```
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
## Usage
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
```ruby
|
|
21
|
+
require "prosemirror"
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
doc = Prosemirror.parse(record.content) # a Hash or a JSON string
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
doc.to_html # => "<h2>Hello</h2><p>World</p>"
|
|
26
|
+
doc.to_text # => "Hello\n\nWorld"
|
|
27
|
+
doc.to_h # => { "type" => "doc", "content" => [...] }
|
|
28
|
+
```
|
|
18
29
|
|
|
19
|
-
|
|
30
|
+
`to_h` gives the document back as JSON, with invalid parts fixed (see [Invalid documents](#invalid-documents)). Attributes the schema doesn't declare, for example from a Tiptap extension it doesn't know, are kept as they are in `unknown_attrs`, and never rendered.
|
|
31
|
+
|
|
32
|
+
In Rails, `to_html` returns an `html_safe` string, and documents can be rendered like other objects in ERB templates and ViewComponents:
|
|
33
|
+
|
|
34
|
+
```erb
|
|
35
|
+
<%= render doc %>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
In a Phlex component, use `to_phlex`, which writes directly into the page:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
render doc.to_phlex
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Documents are `Enumerable`, and nodes support pattern matching:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
doc.select { |node| node in { type: :heading, attrs: { level: 1 } } }
|
|
48
|
+
doc.content.first.text_content
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Schemas
|
|
52
|
+
|
|
53
|
+
Two schemas are included:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
Prosemirror::Schema.tiptap # Tiptap's StarterKit, tables, images, underline, subscript, superscript, highlight (default)
|
|
57
|
+
Prosemirror::Schema.prosemirror # prosemirror-schema-basic and prosemirror-schema-list
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Type names are written in snake_case (`bullet_list`). Use `case: :camel` to write camelCase (`bulletList`), as Tiptap does. Both cases are always accepted on input.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap(case: :camel)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Custom nodes and marks
|
|
67
|
+
|
|
68
|
+
A node type is a Phlex component, and its attributes are Literal properties. The type name comes from the class name (`ImageBlock` → `image_block`).
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class ImageBlock < Prosemirror::NodeType
|
|
72
|
+
group :block
|
|
73
|
+
|
|
74
|
+
prop :src, String
|
|
75
|
+
prop :caption, _String?
|
|
76
|
+
|
|
77
|
+
def view_template
|
|
78
|
+
figure do
|
|
79
|
+
img(src: safe_url(@src))
|
|
80
|
+
figcaption { @caption } if @caption
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap.with(ImageBlock)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
In `view_template`, `node` is the node being rendered, and `render_content(nodes)` renders any of its descendants.
|
|
89
|
+
|
|
90
|
+
When an attribute has another name in JSON, declare it with `json:`:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
prop :text_align, _String?, json: "textAlign"
|
|
94
|
+
```
|
|
20
95
|
|
|
21
|
-
|
|
96
|
+
See the [cookbook](COOKBOOK.md) for recipes, like skipping empty paragraphs.
|
|
97
|
+
|
|
98
|
+
### Content expressions
|
|
99
|
+
|
|
100
|
+
`content` says which children a node can have. It works like a regular expression, with node names or group names instead of characters:
|
|
101
|
+
|
|
102
|
+
| Expression | Meaning |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `"text*"` | zero or more text nodes |
|
|
105
|
+
| `"inline*"` | zero or more nodes of the `inline` group (text, hard breaks…) |
|
|
106
|
+
| `"block+"` | one or more nodes of the `block` group |
|
|
107
|
+
| `"paragraph block*"` | a paragraph, then zero or more blocks |
|
|
108
|
+
| `"(table_cell \| table_header)*"` | zero or more cells or header cells |
|
|
109
|
+
| `"caption?"` | zero or one caption |
|
|
110
|
+
| `"cell{2}"`, `"cell{1,5}"`, `"cell{2,}"` | exactly two, one to five, two or more cells |
|
|
111
|
+
|
|
112
|
+
A node type joins a group with `group`, for example `group :block`. A node type without `content` has no children, like an image.
|
|
113
|
+
|
|
114
|
+
The built-in types use these groups: `block` for paragraphs, headings, lists, tables…, `inline` for text and hard breaks, and `list` for lists.
|
|
115
|
+
|
|
116
|
+
Today, content expressions are used to find the nodes that contain text, for `to_text`. Rendering doesn't check them. They will also be used by HTML import and validation. See [ProseMirror's guide](https://prosemirror.net/docs/guide/#schema.content_expressions) for details.
|
|
117
|
+
|
|
118
|
+
### Other options
|
|
119
|
+
|
|
120
|
+
| Node type option | Meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `group :block, :list` | the groups of the type |
|
|
123
|
+
| `content "inline*"` | the allowed children |
|
|
124
|
+
| `inline` | the node is inline, like text (also implied by `group :inline`) |
|
|
125
|
+
| `marks ""` | the marks allowed inside: names or groups, `"_"` for all, `""` for none |
|
|
126
|
+
| `code` | the node contains code |
|
|
127
|
+
| `whitespace :pre` | keep whitespace when importing HTML (implied by `code`) |
|
|
128
|
+
| `leaf_text "\n"` | the text of a node without children, for `to_text`; also takes a block called with the node |
|
|
129
|
+
| `type_name :image_block` | the type name, when it differs from the class name |
|
|
130
|
+
| `aliases :figure` | other type names accepted on input |
|
|
131
|
+
|
|
132
|
+
| Mark type option | Meaning |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `spanning false` | render one element per node, instead of one for adjacent nodes with the same mark |
|
|
135
|
+
| `excludes "_"` | the marks it can't be combined with |
|
|
136
|
+
| `group`, `code`, `type_name`, `aliases` | as for node types |
|
|
137
|
+
|
|
138
|
+
### Changing built-in types
|
|
139
|
+
|
|
140
|
+
To change how a built-in type renders, subclass it:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
class Paragraph < Prosemirror::Nodes::Paragraph
|
|
144
|
+
def view_template(&) = p(class: "text-lg", &)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap.with(Paragraph)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Invalid documents
|
|
151
|
+
|
|
152
|
+
Bad data doesn't break the page. For an invalid attribute, the gem:
|
|
153
|
+
|
|
154
|
+
1. converts it when the meaning is clear (`"2"` becomes `2`);
|
|
155
|
+
2. otherwise, uses its default;
|
|
156
|
+
3. if it has no default, removes the node or mark (a link with an invalid `href` keeps its text).
|
|
157
|
+
|
|
158
|
+
Unknown nodes render their content. Report problems, or raise them:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
Prosemirror::Schema.tiptap(on_invalid: ->(error) { Rails.error.report(error) })
|
|
162
|
+
Prosemirror::Schema.tiptap(strict: true) # raises Prosemirror::InvalidDocument
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## URLs
|
|
166
|
+
|
|
167
|
+
Links and images allow `http`, `https`, `mailto`, `tel` and relative URLs. Change the list with `url_schemes:`, or rewrite URLs before they are checked with `resolve_url:`:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
Prosemirror::Schema.tiptap(
|
|
171
|
+
url_schemes: %w[https],
|
|
172
|
+
resolve_url: ->(url) { url.start_with?("gid://") ? url_for(GlobalID::Locator.locate(url)) : url }
|
|
173
|
+
)
|
|
174
|
+
```
|
|
22
175
|
|
|
23
176
|
## Development
|
|
24
177
|
|
|
25
|
-
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
178
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests, and `rake` to also run [Standard](https://github.com/standardrb/standard). You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
26
179
|
|
|
27
180
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
28
181
|
|
|
29
182
|
## Contributing
|
|
30
183
|
|
|
31
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
184
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/etaminstudio/prosemirror.
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
data/ROADMAP.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
`prosemirror` is a Ruby gem for [ProseMirror](https://prosemirror.net) and [Tiptap](https://tiptap.dev) documents. It renders them to HTML or text, and imports HTML, Markdown or text into them.
|
|
4
|
+
|
|
5
|
+
Feedback is welcome in the issues.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
ProseMirror editors store documents as JSON. Ruby apps need to render this JSON to HTML, extract its text, and import old HTML or Markdown content. Today, this is split across several small gems:
|
|
10
|
+
|
|
11
|
+
| Gem | Does | Downloads | Limits |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| [`prosemirror_to_html`](https://rubygems.org/gems/prosemirror_to_html) | JSON → HTML | 122k | Builds HTML strings, then cleans them with Loofah |
|
|
14
|
+
| [`html_to_prosemirror`](https://rubygems.org/gems/html_to_prosemirror) | HTML → JSON | 19k | Few node types, regex-based cleanup |
|
|
15
|
+
| [`prose_mirror_ruby`](https://rubygems.org/gems/prose_mirror_ruby) | JSON → Markdown | 78k | Needs ActiveSupport, no HTML |
|
|
16
|
+
| [`prose_render`](https://github.com/mattbarrry/prose_render) (unreleased) | JSON → HTML | — | Needs Rails and ViewComponent |
|
|
17
|
+
|
|
18
|
+
They use different type names (`bullet_list` or `bulletList`, `strong` or `bold`), and none of them uses the same schema for rendering and parsing. `prosemirror` replaces the first two, which have the same maintainers.
|
|
19
|
+
|
|
20
|
+
## Principles
|
|
21
|
+
|
|
22
|
+
- [Phlex](https://www.phlex.fun) builds the HTML and escapes text and attribute values. [Literal](https://literal.fun) types the attributes, so a heading level is an integer from 1 to 6. URLs must use an allowed scheme. Attribute names never come from the document. Nothing is cleaned up after rendering.
|
|
23
|
+
- Output is HTML5 only: no `<strike>`, `<font>` or `align=`. The importer still reads them.
|
|
24
|
+
- Bad data doesn't break the page. For an invalid attribute:
|
|
25
|
+
1. convert it when the meaning is clear (`"2"` becomes `2`);
|
|
26
|
+
2. otherwise, use its default (a heading with `level: "abc"` becomes an `h1`, and keeps its text);
|
|
27
|
+
3. if it has no default, remove what it belongs to: an image without a valid `src`, or a link with an invalid `href` (the text stays).
|
|
28
|
+
|
|
29
|
+
An unknown node renders its content. An `on_invalid` hook reports each problem, for example to an error tracker. A strict mode raises errors instead. `to_h` returns the corrected document.
|
|
30
|
+
- Only `phlex` and `literal` are required: no Rails, no Nokogiri. [`herb`](https://herb-tools.dev) (HTML) and [`commonmarker`](https://github.com/gjtorikian/commonmarker) (Markdown) are loaded only by the importers.
|
|
31
|
+
- Schemas, content expressions, marks and parse rules work as in [ProseMirror's guide](https://prosemirror.net/docs/guide/#schema), with the same JSON format. Methods keep ProseMirror's names in Ruby style (`text_content`, `descendants`, `check`).
|
|
32
|
+
- Nodes are immutable values that support pattern matching. Documents are `Enumerable`. Node types are classes.
|
|
33
|
+
- Each node type defines its attributes, its allowed content, its HTML output, and how to recognize it in HTML. Rendering and import use the same schema.
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
doc = Prosemirror.parse(record.content) # Hash or JSON string
|
|
39
|
+
doc = Prosemirror.from_html(html)
|
|
40
|
+
doc = Prosemirror.from_markdown(markdown)
|
|
41
|
+
doc = Prosemirror.from_text(text)
|
|
42
|
+
|
|
43
|
+
doc.to_html # => "<h2>Hello</h2><p>World</p>"
|
|
44
|
+
doc.to_text # => "Hello\n\nWorld"
|
|
45
|
+
doc.to_h # for a json/jsonb column
|
|
46
|
+
|
|
47
|
+
doc.select { |node| node in { type: :heading, attrs: { level: 1 } } }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Custom nodes
|
|
51
|
+
|
|
52
|
+
A node type is a Phlex component. Its attributes are Literal properties. Its name comes from the class name (`ImageBlock` → `image_block`).
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
class ImageBlock < Prosemirror::NodeType
|
|
56
|
+
group :block
|
|
57
|
+
|
|
58
|
+
prop :src, _String?
|
|
59
|
+
prop :alt, _String?
|
|
60
|
+
|
|
61
|
+
parse_html tag: "img[data-block]" do |element|
|
|
62
|
+
{ src: element["src"], alt: element["alt"] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def view_template
|
|
66
|
+
figure { img(src: safe_url(@src), alt: @alt) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
schema = Prosemirror::Schema.tiptap.with(ImageBlock)
|
|
71
|
+
Prosemirror.parse(json, schema:).to_html
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To change a built-in type, subclass it:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class Heading < Prosemirror::Nodes::Heading
|
|
78
|
+
prop :id, _String?
|
|
79
|
+
|
|
80
|
+
def view_template(&)
|
|
81
|
+
tag(:"h#{@level}", id: @id || slugify(node.text_content), class: "heading", &)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A node can render parts of its content in different places, with `node` and `render_content`:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# A quote whose last line, after a line break, is the author
|
|
90
|
+
class Blockquote < Prosemirror::Nodes::Blockquote
|
|
91
|
+
def view_template
|
|
92
|
+
inline = node.content.flat_map(&:content)
|
|
93
|
+
split = inline.rindex { |child| child.type == :hard_break }
|
|
94
|
+
|
|
95
|
+
figure do
|
|
96
|
+
blockquote { render_content(split ? inline[...split] : inline) }
|
|
97
|
+
figcaption { render_content(inline[(split + 1)..]) } if split
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A `resolve_url` hook can rewrite URLs before they are checked, for example to turn `gid://` references into links:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
Prosemirror::Schema.tiptap(resolve_url: ->(url) { url.start_with?("gid://") ? url_for(GlobalID::Locator.locate(url)) : url })
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
In Rails, a custom node can render view components with [`phlex-rails`](https://github.com/yippee-fun/phlex-rails).
|
|
110
|
+
|
|
111
|
+
### Schemas
|
|
112
|
+
|
|
113
|
+
The gem has two schemas:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
Prosemirror::Schema.tiptap # Tiptap's StarterKit and common extensions (default)
|
|
117
|
+
Prosemirror::Schema.prosemirror # ProseMirror's prosemirror-schema-basic and prosemirror-schema-list
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
They differ in more than names:
|
|
121
|
+
|
|
122
|
+
| | `Schema.tiptap` | `Schema.prosemirror` |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| Marks | `bold`, `italic`, `code`, `link`, `strike`, `underline`… | `strong`, `em`, `code`, `link` |
|
|
125
|
+
| Ordered list | `start` | `order` |
|
|
126
|
+
| Code block | `language` | no attributes |
|
|
127
|
+
| Image | block | inline |
|
|
128
|
+
| Link | `href`, `target`, `rel`, `class` | `href`, `title` |
|
|
129
|
+
|
|
130
|
+
- Both use snake_case type names (`bullet_list`, `hard_break`). `case: :camel` outputs camelCase type names (`bulletList`), as Tiptap does.
|
|
131
|
+
- `case:` doesn't change attribute names. Editors only read the exact names they declare, and Tiptap mixes styles (`textAlign`, `colwidth`). In Ruby, attributes are snake_case properties. A different JSON name is declared on the property (`prop :text_align, _String?, json: "textAlign"`), and the built-in types use Tiptap's exact names. Input accepts both forms.
|
|
132
|
+
- Both accept input in both cases, and with the other schema's names (`strong` for `bold`, `order` for `start`).
|
|
133
|
+
- Nodes that are the same in both (paragraph, heading, blockquote…) use the same classes.
|
|
134
|
+
|
|
135
|
+
## Milestones
|
|
136
|
+
|
|
137
|
+
### 0.1: Rendering
|
|
138
|
+
|
|
139
|
+
- `Node`, `Mark` and `Document` values, built from a Hash or a JSON string, with a nesting limit.
|
|
140
|
+
- Normalization as in ProseMirror: marks sorted by schema order, adjacent text with the same marks joined, empty text removed.
|
|
141
|
+
- `Schema`, `NodeType` and `MarkType`, with the options of ProseMirror's `NodeSpec` and `MarkSpec`: `content`, `group`, `inline`, `marks`, `whitespace`, `leaf_text`, `excludes`, `spanning`.
|
|
142
|
+
- `Schema.tiptap`: `doc`, `paragraph`, `text`, `heading`, `blockquote`, `bullet_list`, `ordered_list`, `list_item`, `code_block`, `hard_break`, `horizontal_rule`, `image`, `table`, `table_row`, `table_header`, `table_cell`. Marks: `link`, `bold`, `italic`, `underline`, `strike`, `code`, `subscript`, `superscript`, `highlight`.
|
|
143
|
+
- `Schema.prosemirror`: exactly the nodes and marks of `prosemirror-schema-basic` and `prosemirror-schema-list`.
|
|
144
|
+
- `to_html`, with one tag for a mark shared by adjacent text (one `<a>` for a link with mixed formatting).
|
|
145
|
+
- `to_text`, like Tiptap's `getText`.
|
|
146
|
+
- `case: :camel`, name aliases, allowed URL schemes, `resolve_url`.
|
|
147
|
+
- One JSON test fixture per node and mark.
|
|
148
|
+
- Security tests, starting with every issue fixed in `prosemirror_to_html`:
|
|
149
|
+
- unescaped attribute values ([CVE-2025-64501](https://github.com/etaminstudio/prosemirror_to_html/security/advisories/GHSA-52c5-vh7f-26fx));
|
|
150
|
+
- attribute names from the document, like `onmouseover`, and `javascript:` URLs ([GHSA-6gj3-5qf2-wwvv](https://github.com/etaminstudio/prosemirror_to_html/security/advisories/GHSA-6gj3-5qf2-wwvv));
|
|
151
|
+
- a heading `level` like `1><script>`;
|
|
152
|
+
- other URL schemes, hidden schemes (`java\tscript:`), `style` injection, deep nesting.
|
|
153
|
+
|
|
154
|
+
### 0.2: HTML import
|
|
155
|
+
|
|
156
|
+
- `Prosemirror.from_html`, built on [Herb](https://herb-tools.dev), which doesn't raise on bad HTML and ships with Rails 8.2.
|
|
157
|
+
- The importer also needs to:
|
|
158
|
+
- decode entities (`&`, `é`), with the [WHATWG entity list](https://html.spec.whatwg.org/entities.json);
|
|
159
|
+
- keep `<% %>` in content as text;
|
|
160
|
+
- apply unclosed tags (`<strong>` without `</strong>`) to the rest of their parent;
|
|
161
|
+
- walk the tree without recursion, with a depth limit.
|
|
162
|
+
- Parse rules like ProseMirror's `ParseRule`: simple CSS selectors, `style` rules, `attrs`, `priority`, `context`, `ignore`, `skip`, `preserve_whitespace`, and more.
|
|
163
|
+
- ProseMirror's `DOMParser` behavior: collapse whitespace, wrap loose text in paragraphs, add missing wrappers (an `<li>` outside a list), unwrap unknown elements, ignore `script`, `style` and similar.
|
|
164
|
+
- Marks from inline styles (`font-weight: bold`), for content pasted from Google Docs or Word.
|
|
165
|
+
- Old HTML mapped to the schema: `<b>` and `<i>` to `bold` and `italic`, `<strike>` to `strike`, `<tt>` to `code`. `<font>` and `<center>` are unwrapped.
|
|
166
|
+
- Tests: HTML from Tiptap gives the same JSON as Tiptap.
|
|
167
|
+
|
|
168
|
+
### 0.3: Markdown and text import
|
|
169
|
+
|
|
170
|
+
- `Prosemirror.from_markdown`, built from [`commonmarker`](https://github.com/gjtorikian/commonmarker)'s syntax tree, without going through HTML (like `prosemirror-markdown`). commonmarker's parser is written in Rust.
|
|
171
|
+
- GitHub Flavored Markdown: tables, strikethrough, autolinks, task lists.
|
|
172
|
+
- Custom node types can define their Markdown mapping.
|
|
173
|
+
- Raw HTML in Markdown goes through the HTML importer if Herb is installed.
|
|
174
|
+
- `Prosemirror.from_text`: blank lines make paragraphs, line breaks make hard breaks.
|
|
175
|
+
|
|
176
|
+
### 0.4: Working with documents
|
|
177
|
+
|
|
178
|
+
- Transformations that return a new document: rewrite links, unwrap lists, split paragraphs, remove empty ones.
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
doc.transform do |node|
|
|
182
|
+
case node
|
|
183
|
+
in { type: :bullet_list } then node.content.map { |item| item.content.first }
|
|
184
|
+
in { type: :paragraph } if node.blank? then nil
|
|
185
|
+
else node
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
- Validation against the schema (`check`).
|
|
190
|
+
|
|
191
|
+
### 0.5: Markdown export
|
|
192
|
+
|
|
193
|
+
- `doc.to_markdown`, like [`prosemirror-markdown`](https://github.com/ProseMirror/prosemirror-markdown). Useful to send content to LLMs.
|
|
194
|
+
- Help wanted: it is self-contained and a good first contribution.
|
|
195
|
+
|
|
196
|
+
### 0.6: More Tiptap node types
|
|
197
|
+
|
|
198
|
+
- Task lists, text alignment, mentions, details, embeds.
|
|
199
|
+
|
|
200
|
+
### 1.0
|
|
201
|
+
|
|
202
|
+
- Stable API, documentation site, benchmarks.
|
|
203
|
+
- Migration guides from `prosemirror_to_html` and `html_to_prosemirror`, and final releases of both pointing to `prosemirror`.
|
|
204
|
+
- Used in production by [Trek](https://trek.etamin.studio), a Rails CMS that renders Tiptap documents with custom nodes.
|
|
205
|
+
|
|
206
|
+
## Out of scope
|
|
207
|
+
|
|
208
|
+
- CSS framework themes: subclass node types instead.
|
|
209
|
+
- A Rails or ViewComponent dependency.
|
|
210
|
+
- Editing features: positions, steps, commands, collaboration.
|
|
211
|
+
- The editor itself.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# Conversions for property values, used as Literal coercion blocks:
|
|
5
|
+
#
|
|
6
|
+
# prop :level, _Integer(1..6), default: 1, &Coerce::INTEGER
|
|
7
|
+
module Coerce
|
|
8
|
+
# "2" becomes 2.
|
|
9
|
+
INTEGER = proc { |value| (value.is_a?(String) && value.match?(/\A\s*-?\d+\s*\z/)) ? value.to_i : value }
|
|
10
|
+
|
|
11
|
+
# "bullet_list" becomes :bullet_list.
|
|
12
|
+
SYMBOL = proc { |value| value.is_a?(String) ? value.to_sym : value }
|
|
13
|
+
|
|
14
|
+
# A frozen copy, unless the value is already frozen.
|
|
15
|
+
FROZEN = proc { |value| value.frozen? ? value : value.dup.freeze }
|
|
16
|
+
end
|
|
17
|
+
end
|