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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/COOKBOOK.md +108 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +169 -12
  6. data/ROADMAP.md +211 -0
  7. data/lib/prosemirror/coerce.rb +17 -0
  8. data/lib/prosemirror/content_expression.rb +94 -0
  9. data/lib/prosemirror/document.rb +76 -0
  10. data/lib/prosemirror/html_renderer.rb +20 -0
  11. data/lib/prosemirror/json_parser.rb +125 -0
  12. data/lib/prosemirror/mark.rb +19 -0
  13. data/lib/prosemirror/mark_type.rb +60 -0
  14. data/lib/prosemirror/marks/basic/em.rb +13 -0
  15. data/lib/prosemirror/marks/basic/link.rb +20 -0
  16. data/lib/prosemirror/marks/basic/strong.rb +13 -0
  17. data/lib/prosemirror/marks/bold.rb +11 -0
  18. data/lib/prosemirror/marks/code.rb +11 -0
  19. data/lib/prosemirror/marks/highlight.rb +16 -0
  20. data/lib/prosemirror/marks/italic.rb +11 -0
  21. data/lib/prosemirror/marks/link.rb +21 -0
  22. data/lib/prosemirror/marks/strike.rb +9 -0
  23. data/lib/prosemirror/marks/subscript.rb +11 -0
  24. data/lib/prosemirror/marks/superscript.rb +11 -0
  25. data/lib/prosemirror/marks/underline.rb +9 -0
  26. data/lib/prosemirror/naming.rb +16 -0
  27. data/lib/prosemirror/node.rb +65 -0
  28. data/lib/prosemirror/node_type.rb +101 -0
  29. data/lib/prosemirror/nodes/basic/code_block.rb +19 -0
  30. data/lib/prosemirror/nodes/basic/image.rb +22 -0
  31. data/lib/prosemirror/nodes/basic/ordered_list.rb +19 -0
  32. data/lib/prosemirror/nodes/blockquote.rb +12 -0
  33. data/lib/prosemirror/nodes/bullet_list.rb +12 -0
  34. data/lib/prosemirror/nodes/code_block.rb +19 -0
  35. data/lib/prosemirror/nodes/doc.rb +10 -0
  36. data/lib/prosemirror/nodes/hard_break.rb +13 -0
  37. data/lib/prosemirror/nodes/heading.rb +14 -0
  38. data/lib/prosemirror/nodes/horizontal_rule.rb +11 -0
  39. data/lib/prosemirror/nodes/image.rb +21 -0
  40. data/lib/prosemirror/nodes/list_item.rb +11 -0
  41. data/lib/prosemirror/nodes/ordered_list.rb +18 -0
  42. data/lib/prosemirror/nodes/paragraph.rb +12 -0
  43. data/lib/prosemirror/nodes/table.rb +14 -0
  44. data/lib/prosemirror/nodes/table_cell.rb +23 -0
  45. data/lib/prosemirror/nodes/table_header.rb +13 -0
  46. data/lib/prosemirror/nodes/table_row.rb +11 -0
  47. data/lib/prosemirror/nodes/text.rb +10 -0
  48. data/lib/prosemirror/rendering.rb +70 -0
  49. data/lib/prosemirror/schema.rb +190 -0
  50. data/lib/prosemirror/text_serializer.rb +46 -0
  51. data/lib/prosemirror/type_definition.rb +119 -0
  52. data/lib/prosemirror/version.rb +1 -1
  53. data/lib/prosemirror.rb +33 -2
  54. metadata +101 -16
  55. data/.rubocop.yml +0 -13
  56. data/Gemfile +0 -12
  57. data/Rakefile +0 -16
  58. 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: 7d4941e1033d3813a090427d6d94aac49e2ec017e24f5b1f4a3d8fe4f2600e29
4
+ data.tar.gz: 04b280abbbe4cb7d997f2c834696faba70e93cf1956a41f9516eeef62fc7ff2a
5
5
  SHA512:
6
- metadata.gz: 7c6dee0d8cf9873e8e9eeb79d61b99f067a40e9046c8e531cf71576a2655668bc590b67749c2fdc791be8f085134307a9b111232425739d9f8a6e04e660f322e
7
- data.tar.gz: c4ab45d99353f27037254abec0f38dfc21c4608beb6a535eb015094b7b263b699767782347c17fa0f1efaa3ebd4837ec0b3e6f0f5be369ec40bb4a202c2e4e74
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
- # 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, 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). HTML and Markdown import are 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
+ ## 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
- TODO: Write usage instructions here
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/[USERNAME]/prosemirror.
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 (`&amp;`, `&eacute;`), 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