prosemirror 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/COOKBOOK.md +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +155 -12
- data/ROADMAP.md +215 -0
- data/TYPES.md +201 -0
- data/lib/prosemirror/coerce.rb +17 -0
- data/lib/prosemirror/content_expression.rb +94 -0
- data/lib/prosemirror/content_match.rb +241 -0
- data/lib/prosemirror/document.rb +76 -0
- data/lib/prosemirror/html/builder.rb +146 -0
- data/lib/prosemirror/html/element.rb +84 -0
- data/lib/prosemirror/html/entities.rb +87 -0
- data/lib/prosemirror/html/entities.txt +2231 -0
- data/lib/prosemirror/html/selector.rb +95 -0
- data/lib/prosemirror/html/splitter.rb +68 -0
- data/lib/prosemirror/html/text.rb +23 -0
- data/lib/prosemirror/html_parser.rb +498 -0
- data/lib/prosemirror/html_renderer.rb +20 -0
- data/lib/prosemirror/json_parser.rb +114 -0
- data/lib/prosemirror/mark.rb +19 -0
- data/lib/prosemirror/mark_type.rb +71 -0
- data/lib/prosemirror/marks/basic/code.rb +16 -0
- data/lib/prosemirror/marks/basic/em.rb +17 -0
- data/lib/prosemirror/marks/basic/link.rb +21 -0
- data/lib/prosemirror/marks/basic/strong.rb +17 -0
- data/lib/prosemirror/marks/bold.rb +19 -0
- data/lib/prosemirror/marks/code.rb +16 -0
- data/lib/prosemirror/marks/highlight.rb +18 -0
- data/lib/prosemirror/marks/italic.rb +15 -0
- data/lib/prosemirror/marks/link.rb +22 -0
- data/lib/prosemirror/marks/strike.rb +14 -0
- data/lib/prosemirror/marks/subscript.rb +13 -0
- data/lib/prosemirror/marks/superscript.rb +13 -0
- data/lib/prosemirror/marks/underline.rb +12 -0
- data/lib/prosemirror/naming.rb +16 -0
- data/lib/prosemirror/node.rb +77 -0
- data/lib/prosemirror/node_type.rb +123 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +20 -0
- data/lib/prosemirror/nodes/basic/image.rb +23 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +22 -0
- data/lib/prosemirror/nodes/blockquote.rb +13 -0
- data/lib/prosemirror/nodes/bullet_list.rb +13 -0
- data/lib/prosemirror/nodes/code_block.rb +24 -0
- data/lib/prosemirror/nodes/doc.rb +10 -0
- data/lib/prosemirror/nodes/hard_break.rb +15 -0
- data/lib/prosemirror/nodes/heading.rb +15 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +12 -0
- data/lib/prosemirror/nodes/image.rb +22 -0
- data/lib/prosemirror/nodes/list_item.rb +12 -0
- data/lib/prosemirror/nodes/ordered_list.rb +36 -0
- data/lib/prosemirror/nodes/paragraph.rb +13 -0
- data/lib/prosemirror/nodes/table.rb +15 -0
- data/lib/prosemirror/nodes/table_cell.rb +33 -0
- data/lib/prosemirror/nodes/table_header.rb +15 -0
- data/lib/prosemirror/nodes/table_row.rb +12 -0
- data/lib/prosemirror/nodes/text.rb +10 -0
- data/lib/prosemirror/parse_rule.rb +50 -0
- data/lib/prosemirror/rendering.rb +70 -0
- data/lib/prosemirror/schema.rb +241 -0
- data/lib/prosemirror/text_serializer.rb +46 -0
- data/lib/prosemirror/type_definition.rb +149 -0
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +43 -2
- metadata +113 -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: 1006b6a2ecb95f5a4d603d5d9321bbdab419f26f882365ff14d1692f27970ce7
|
|
4
|
+
data.tar.gz: 22734518a2e81ecaba0ff64ed5057f40705dfb55dcd919567673f88e7cbf7e45
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f4b24b76e78e49523f068aa8520304c955428d4ff295c0e0f918dfad07ad0bc5b7ee391b8dacaeabf258f871d99f2dcfba15e4ebad4bec961e97529cdd24f34
|
|
7
|
+
data.tar.gz: be218156ef3b417342de7d393d219b5019fe6f270a9c865e5cce9b5f9a2436008f59d5ada72d0111e09152586a06e21a7e98653ce4002923049eb0e70331daa0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-10-23
|
|
4
|
+
|
|
5
|
+
- Import HTML with `Prosemirror.from_html` (or `schema.parse_html`), as ProseMirror's `DOMParser` does. Needs the [`herb`](https://herb-tools.dev) gem, which isn't installed with this gem.
|
|
6
|
+
- Node and mark types declare how to recognize them in HTML with `parse_html`, like ProseMirror's parse rules, and attributes with `prop …, parse_html:`. The built-in types have Tiptap's rules.
|
|
7
|
+
- Table cells have an `align` attribute (`left`, `right` or `center`), rendered as a `text-align` style.
|
|
8
|
+
- `Schema.tiptap` sorts marks in Tiptap's order: `code` now comes before `italic`, and `strike` before `underline`.
|
|
9
|
+
- Tiptap's `code` mark can't be combined with other marks, as in Tiptap. `Schema.prosemirror` uses a new `Marks::Basic::Code`, which can.
|
|
10
|
+
- `TYPES.md` documents node and mark types: options, attributes, rendering helpers and parse rules.
|
|
11
|
+
- Custom types can pass Array, Hash or Date attribute values to Phlex outside Rails. Phlex 2.4.1 raised a `NameError` without the `date` library.
|
|
12
|
+
|
|
13
|
+
## [0.1.0] - 2026-10-23
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
- `Schema.tiptap` and `Schema.prosemirror`, with `case: :camel` for camelCase type names.
|
|
17
|
+
- Custom node and mark types, as Phlex components with Literal attributes.
|
|
18
|
+
- Invalid attributes are converted, replaced by their default, or removed, and reported with `on_invalid`. `strict: true` raises instead.
|
|
19
|
+
- Attributes a node or mark type doesn't declare are kept in `unknown_attrs`: never rendered, and given back by `to_h`.
|
|
20
|
+
- 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.
|
|
21
|
+
- A cookbook with recipes: skip empty paragraphs, heading anchors, render your own components, links to records with GlobalID.
|
|
22
|
+
|
|
3
23
|
## [0.0.1] - 2023-07-03
|
|
4
24
|
|
|
5
25
|
- 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,174 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ProseMirror
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Render [ProseMirror](https://prosemirror.net) and [Tiptap](https://tiptap.dev) documents to HTML and text, and import HTML into them, 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). Markdown import is 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
|
+
## HTML import
|
|
52
|
+
|
|
53
|
+
HTML import uses [Herb](https://herb-tools.dev)'s parser, which never raises on malformed HTML. Add it to your Gemfile:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
bundle add herb
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
doc = Prosemirror.from_html("<h2>Hello</h2><p>World</p>")
|
|
61
|
+
doc.to_h # => { "type" => "doc", "content" => [{ "type" => "heading", ... }, ...] }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It works like ProseMirror's `DOMParser`, and for the built-in types gives the same JSON as Tiptap's `generateJSON`, apart from invalid attributes:
|
|
65
|
+
|
|
66
|
+
- each node and mark type recognizes its elements with its parse rules: `<b>`, `<strong>` and `font-weight: bold` all give `bold`;
|
|
67
|
+
- whitespace is collapsed, except in code blocks;
|
|
68
|
+
- content that doesn't fit where it is gets wrapped or moved: loose text becomes a paragraph, an `<li>` outside a list gets a list;
|
|
69
|
+
- elements without a rule are unwrapped, and `script`, `style` and similar are ignored;
|
|
70
|
+
- entities are decoded, and ERB tags (`<%= … %>`) are kept as text;
|
|
71
|
+
- invalid attributes are fixed and reported, as in [Invalid documents](#invalid-documents).
|
|
72
|
+
|
|
73
|
+
## Schemas
|
|
74
|
+
|
|
75
|
+
Two schemas are included:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
Prosemirror::Schema.tiptap # Tiptap's StarterKit, tables, images, underline, subscript, superscript, highlight (default)
|
|
79
|
+
Prosemirror::Schema.prosemirror # prosemirror-schema-basic and prosemirror-schema-list
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
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.
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap(case: :camel)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Custom nodes and marks
|
|
89
|
+
|
|
90
|
+
A node type is a Phlex component, and its attributes are Literal properties. The type name comes from the class name (`ImageBlock` → `image_block`).
|
|
20
91
|
|
|
21
|
-
|
|
92
|
+
```ruby
|
|
93
|
+
class ImageBlock < Prosemirror::NodeType
|
|
94
|
+
group :block
|
|
95
|
+
|
|
96
|
+
prop :src, String
|
|
97
|
+
prop :caption, _String?
|
|
98
|
+
|
|
99
|
+
def view_template
|
|
100
|
+
figure do
|
|
101
|
+
img(src: safe_url(@src))
|
|
102
|
+
figcaption { @caption } if @caption
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap.with(ImageBlock)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
In `view_template`, `node` is the node being rendered, and `render_content(nodes)` renders any of its descendants.
|
|
111
|
+
|
|
112
|
+
`parse_html` says which elements become this type when importing HTML, and `prop … parse_html:` how to read an attribute:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
class ImageBlock < Prosemirror::NodeType
|
|
116
|
+
prop :caption, _String?, parse_html: ->(element) { element.query("figcaption")&.text_content }
|
|
117
|
+
|
|
118
|
+
parse_html(tag: "figure.image") { |element| {src: element.query("img")&.[]("src")} }
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
[Node and mark types](TYPES.md) documents every option: groups, content expressions, allowed marks, attribute names in JSON, rendering helpers, and parse rules. See the [cookbook](COOKBOOK.md) for recipes, like skipping empty paragraphs.
|
|
123
|
+
|
|
124
|
+
### Changing built-in types
|
|
125
|
+
|
|
126
|
+
To change how a built-in type renders, subclass it:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class Paragraph < Prosemirror::Nodes::Paragraph
|
|
130
|
+
def view_template(&) = p(class: "text-lg", &)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap.with(Paragraph)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Invalid documents
|
|
137
|
+
|
|
138
|
+
Bad data doesn't break the page. For an invalid attribute, the gem:
|
|
139
|
+
|
|
140
|
+
1. converts it when the meaning is clear (`"2"` becomes `2`);
|
|
141
|
+
2. otherwise, uses its default;
|
|
142
|
+
3. if it has no default, removes the node or mark (a link with an invalid `href` keeps its text).
|
|
143
|
+
|
|
144
|
+
Unknown nodes render their content. Report problems, or raise them:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
Prosemirror::Schema.tiptap(on_invalid: ->(error) { Rails.error.report(error) })
|
|
148
|
+
Prosemirror::Schema.tiptap(strict: true) # raises Prosemirror::InvalidDocument
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## URLs
|
|
152
|
+
|
|
153
|
+
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:`:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
Prosemirror::Schema.tiptap(
|
|
157
|
+
url_schemes: %w[https],
|
|
158
|
+
resolve_url: ->(url) { url.start_with?("gid://") ? url_for(GlobalID::Locator.locate(url)) : url }
|
|
159
|
+
)
|
|
160
|
+
```
|
|
22
161
|
|
|
23
162
|
## Development
|
|
24
163
|
|
|
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.
|
|
164
|
+
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
165
|
|
|
27
166
|
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
167
|
|
|
29
168
|
## Contributing
|
|
30
169
|
|
|
31
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
170
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/etaminstudio/prosemirror.
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
data/ROADMAP.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
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 (released)
|
|
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
|
+
- Attributes a type doesn't declare kept in `unknown_attrs`: never rendered, and given back by `to_h`.
|
|
142
|
+
- `Schema`, `NodeType` and `MarkType`, with the options of ProseMirror's `NodeSpec` and `MarkSpec`: `content`, `group`, `inline`, `marks`, `whitespace`, `leaf_text`, `excludes`, `spanning`. `marks`, `whitespace` and `excludes` are declared, but only take effect with HTML import (0.2) and validation (0.4), as ProseMirror's `fromJSON` doesn't enforce them either.
|
|
143
|
+
- `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`.
|
|
144
|
+
- `Schema.prosemirror`: exactly the nodes and marks of `prosemirror-schema-basic` and `prosemirror-schema-list`.
|
|
145
|
+
- `to_html`, with one tag for a mark shared by adjacent text (one `<a>` for a link with mixed formatting).
|
|
146
|
+
- `to_phlex` to render in Phlex components, and `render doc` in ERB templates and ViewComponents.
|
|
147
|
+
- `node` and `render_content` in node types, to render parts of a node's content.
|
|
148
|
+
- `to_text`, like Tiptap's `getText`.
|
|
149
|
+
- `case: :camel`, name aliases, allowed URL schemes, `resolve_url` (a URL it can't resolve is removed and reported).
|
|
150
|
+
- One JSON test fixture per node and mark, for both schemas.
|
|
151
|
+
- A [cookbook](COOKBOOK.md).
|
|
152
|
+
- Security tests, starting with every issue fixed in `prosemirror_to_html`:
|
|
153
|
+
- unescaped attribute values ([CVE-2025-64501](https://github.com/etaminstudio/prosemirror_to_html/security/advisories/GHSA-52c5-vh7f-26fx));
|
|
154
|
+
- 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));
|
|
155
|
+
- a heading `level` like `1><script>`;
|
|
156
|
+
- other URL schemes, hidden schemes (`java\tscript:`), `style` injection, deep nesting.
|
|
157
|
+
|
|
158
|
+
### 0.2: HTML import (released)
|
|
159
|
+
|
|
160
|
+
- `Prosemirror.from_html`, built on [Herb](https://herb-tools.dev), which doesn't raise on bad HTML.
|
|
161
|
+
- The importer also needs to:
|
|
162
|
+
- decode entities (`&`, `é`), with the [WHATWG entity list](https://html.spec.whatwg.org/entities.json);
|
|
163
|
+
- keep `<% %>` in content as text;
|
|
164
|
+
- apply unclosed tags (`<strong>` without `</strong>`) to the rest of their parent;
|
|
165
|
+
- walk the tree without recursion, with a depth limit.
|
|
166
|
+
- Parse rules like ProseMirror's `ParseRule`: simple CSS selectors, `style` rules, `attrs`, `priority`, `context`, `ignore`, `skip`, `preserve_whitespace`, and more.
|
|
167
|
+
- 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.
|
|
168
|
+
- Marks from inline styles (`font-weight: bold`), for content pasted from Google Docs or Word.
|
|
169
|
+
- Old HTML mapped to the schema: `<b>` and `<i>` to `bold` and `italic`, `<strike>` to `strike`, `<tt>` to `code`. `<font>` and `<center>` are unwrapped.
|
|
170
|
+
- Tests: HTML from Tiptap gives the same JSON as Tiptap.
|
|
171
|
+
|
|
172
|
+
### 0.3: Markdown and text import
|
|
173
|
+
|
|
174
|
+
- `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.
|
|
175
|
+
- GitHub Flavored Markdown: tables, strikethrough, autolinks, task lists.
|
|
176
|
+
- Custom node types can define their Markdown mapping.
|
|
177
|
+
- Raw HTML in Markdown goes through the HTML importer if Herb is installed.
|
|
178
|
+
- `Prosemirror.from_text`: blank lines make paragraphs, line breaks make hard breaks.
|
|
179
|
+
|
|
180
|
+
### 0.4: Working with documents
|
|
181
|
+
|
|
182
|
+
- Transformations that return a new document: rewrite links, unwrap lists, split paragraphs, remove empty ones.
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
doc.transform do |node|
|
|
186
|
+
case node
|
|
187
|
+
in { type: :bullet_list } then node.content.map { |item| item.content.first }
|
|
188
|
+
in { type: :paragraph } if node.blank? then nil
|
|
189
|
+
else node
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
- Validation against the schema (`check`).
|
|
194
|
+
|
|
195
|
+
### 0.5: Markdown export
|
|
196
|
+
|
|
197
|
+
- `doc.to_markdown`, like [`prosemirror-markdown`](https://github.com/ProseMirror/prosemirror-markdown). Useful to send content to LLMs.
|
|
198
|
+
- Help wanted: it is self-contained and a good first contribution.
|
|
199
|
+
|
|
200
|
+
### 0.6: More Tiptap node types
|
|
201
|
+
|
|
202
|
+
- Task lists, text alignment, mentions, details, embeds.
|
|
203
|
+
|
|
204
|
+
### 1.0
|
|
205
|
+
|
|
206
|
+
- Stable API, documentation site, benchmarks.
|
|
207
|
+
- Migration guides from `prosemirror_to_html` and `html_to_prosemirror`, and final releases of both pointing to `prosemirror`.
|
|
208
|
+
- Used in production by [Trek](https://trek.etamin.studio), a Rails CMS that renders Tiptap documents with custom nodes.
|
|
209
|
+
|
|
210
|
+
## Out of scope
|
|
211
|
+
|
|
212
|
+
- CSS framework themes: subclass node types instead.
|
|
213
|
+
- A Rails or ViewComponent dependency.
|
|
214
|
+
- Editing features: positions, steps, commands, collaboration.
|
|
215
|
+
- The editor itself.
|