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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/COOKBOOK.md +108 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +155 -12
  6. data/ROADMAP.md +215 -0
  7. data/TYPES.md +201 -0
  8. data/lib/prosemirror/coerce.rb +17 -0
  9. data/lib/prosemirror/content_expression.rb +94 -0
  10. data/lib/prosemirror/content_match.rb +241 -0
  11. data/lib/prosemirror/document.rb +76 -0
  12. data/lib/prosemirror/html/builder.rb +146 -0
  13. data/lib/prosemirror/html/element.rb +84 -0
  14. data/lib/prosemirror/html/entities.rb +87 -0
  15. data/lib/prosemirror/html/entities.txt +2231 -0
  16. data/lib/prosemirror/html/selector.rb +95 -0
  17. data/lib/prosemirror/html/splitter.rb +68 -0
  18. data/lib/prosemirror/html/text.rb +23 -0
  19. data/lib/prosemirror/html_parser.rb +498 -0
  20. data/lib/prosemirror/html_renderer.rb +20 -0
  21. data/lib/prosemirror/json_parser.rb +114 -0
  22. data/lib/prosemirror/mark.rb +19 -0
  23. data/lib/prosemirror/mark_type.rb +71 -0
  24. data/lib/prosemirror/marks/basic/code.rb +16 -0
  25. data/lib/prosemirror/marks/basic/em.rb +17 -0
  26. data/lib/prosemirror/marks/basic/link.rb +21 -0
  27. data/lib/prosemirror/marks/basic/strong.rb +17 -0
  28. data/lib/prosemirror/marks/bold.rb +19 -0
  29. data/lib/prosemirror/marks/code.rb +16 -0
  30. data/lib/prosemirror/marks/highlight.rb +18 -0
  31. data/lib/prosemirror/marks/italic.rb +15 -0
  32. data/lib/prosemirror/marks/link.rb +22 -0
  33. data/lib/prosemirror/marks/strike.rb +14 -0
  34. data/lib/prosemirror/marks/subscript.rb +13 -0
  35. data/lib/prosemirror/marks/superscript.rb +13 -0
  36. data/lib/prosemirror/marks/underline.rb +12 -0
  37. data/lib/prosemirror/naming.rb +16 -0
  38. data/lib/prosemirror/node.rb +77 -0
  39. data/lib/prosemirror/node_type.rb +123 -0
  40. data/lib/prosemirror/nodes/basic/code_block.rb +20 -0
  41. data/lib/prosemirror/nodes/basic/image.rb +23 -0
  42. data/lib/prosemirror/nodes/basic/ordered_list.rb +22 -0
  43. data/lib/prosemirror/nodes/blockquote.rb +13 -0
  44. data/lib/prosemirror/nodes/bullet_list.rb +13 -0
  45. data/lib/prosemirror/nodes/code_block.rb +24 -0
  46. data/lib/prosemirror/nodes/doc.rb +10 -0
  47. data/lib/prosemirror/nodes/hard_break.rb +15 -0
  48. data/lib/prosemirror/nodes/heading.rb +15 -0
  49. data/lib/prosemirror/nodes/horizontal_rule.rb +12 -0
  50. data/lib/prosemirror/nodes/image.rb +22 -0
  51. data/lib/prosemirror/nodes/list_item.rb +12 -0
  52. data/lib/prosemirror/nodes/ordered_list.rb +36 -0
  53. data/lib/prosemirror/nodes/paragraph.rb +13 -0
  54. data/lib/prosemirror/nodes/table.rb +15 -0
  55. data/lib/prosemirror/nodes/table_cell.rb +33 -0
  56. data/lib/prosemirror/nodes/table_header.rb +15 -0
  57. data/lib/prosemirror/nodes/table_row.rb +12 -0
  58. data/lib/prosemirror/nodes/text.rb +10 -0
  59. data/lib/prosemirror/parse_rule.rb +50 -0
  60. data/lib/prosemirror/rendering.rb +70 -0
  61. data/lib/prosemirror/schema.rb +241 -0
  62. data/lib/prosemirror/text_serializer.rb +46 -0
  63. data/lib/prosemirror/type_definition.rb +149 -0
  64. data/lib/prosemirror/version.rb +1 -1
  65. data/lib/prosemirror.rb +43 -2
  66. metadata +113 -16
  67. data/.rubocop.yml +0 -13
  68. data/Gemfile +0 -12
  69. data/Rakefile +0 -16
  70. data/prosemirror.gemspec +0 -35
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9930d278779ec6b0103e6ed49ad729967b9de2de4041e9d3c375c287318ad346
4
- data.tar.gz: b749da9ec576d1f8044e43a27eaa1be3f7416f8e5d72b4d897b55e2c7e628507
3
+ metadata.gz: 1006b6a2ecb95f5a4d603d5d9321bbdab419f26f882365ff14d1692f27970ce7
4
+ data.tar.gz: 22734518a2e81ecaba0ff64ed5057f40705dfb55dcd919567673f88e7cbf7e45
5
5
  SHA512:
6
- metadata.gz: 7c6dee0d8cf9873e8e9eeb79d61b99f067a40e9046c8e531cf71576a2655668bc590b67749c2fdc791be8f085134307a9b111232425739d9f8a6e04e660f322e
7
- data.tar.gz: c4ab45d99353f27037254abec0f38dfc21c4608beb6a535eb015094b7b263b699767782347c17fa0f1efaa3ebd4837ec0b3e6f0f5be369ec40bb4a202c2e4e74
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
- # Prosemirror
1
+ # ProseMirror
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
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
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/prosemirror`. To experiment with that code, run `bin/console` for an interactive prompt.
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
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
14
+ ```sh
15
+ bundle add prosemirror
16
+ ```
10
17
 
11
- Install the gem and add to the application's Gemfile by executing:
18
+ ## Usage
12
19
 
13
- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
20
+ ```ruby
21
+ require "prosemirror"
14
22
 
15
- If bundler is not being used to manage dependencies, install the gem by executing:
23
+ doc = Prosemirror.parse(record.content) # a Hash or a JSON string
16
24
 
17
- $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
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
- ## Usage
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
- TODO: Write usage instructions here
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/[USERNAME]/prosemirror.
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 (`&amp;`, `&eacute;`), 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.