prosemirror 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a11c83675790681267f7d5282882f967154892b408d8965e528b1c34d166876
4
- data.tar.gz: 14ae8422a61cbe0be055d6ef7590a10981014430978089e14b2805ebf3100be2
3
+ metadata.gz: c84fca02d8a8c1f32c9162da3a22133f2ef774c035281331d7d92423d24a242f
4
+ data.tar.gz: ca2ec139725747838a359f43a667c1fc4b5b5249b7dba12775ffbe39985f0c38
5
5
  SHA512:
6
- metadata.gz: 02ac22023a65ba9631752cf6c42b9895cfb4be99acc4d2b1922d0a9742e5d61c7d084455b0c6aec2f0c80400531870113b2ceba519f0a7a4af701de7e192c1f0
7
- data.tar.gz: 8adae01f43c5ea57367db4d77605ceb28b4d606829b905440a716c64dc483c82a105898878349bb79f1fd73df4459651e9172d67546ee1d23968fd8cd3538801
6
+ metadata.gz: c018119a21927872e1382b1ee05f33c397781c87520b55ee4ca76f06abc2fb728ed4122043f11250cd3f8195c5ede8de4e50619bf98f93405f163fa30bd413aa
7
+ data.tar.gz: 3a870e5aceacd20c1ec717942f3dc4c75a97f811e8813e73c346aa71b919ab4430645593c6daec6132a1c72cd3f053e192ff812126cf22cd8f567cc5cafb8b98
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-10-24
4
+
5
+ - `Document#transform` builds a new document from a block called with each node, children first, which keeps, replaces, removes or splices nodes. Required content that goes missing is added back and reported.
6
+ - `Document#check` raises `InvalidDocument` for the first problem with the schema, with its path, and `Document#valid?` returns true or false.
7
+ - `Node#blank?` is true for nodes that show nothing, like empty paragraphs.
8
+
3
9
  ## [0.3.0] - 2026-10-24
4
10
 
5
11
  - Import Markdown with `Prosemirror.from_markdown` (or `schema.parse_markdown`). Needs the [`commonmarker`](https://github.com/gjtorikian/commonmarker) gem, which isn't installed with this gem. GitHub Flavored Markdown tables, strikethrough and autolinks are supported, and the schema's parse rules apply, so custom types can match Markdown too. Raw HTML goes through HTML import when the herb gem is installed. YAML front matter is dropped.
data/COOKBOOK.md CHANGED
@@ -18,23 +18,35 @@ class Paragraph < Prosemirror::Nodes::Paragraph
18
18
  end
19
19
  ```
20
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:
21
+ A paragraph that only holds spaces or line breaks is not empty. To skip those too, use `blank?`, which keeps paragraphs with an inline image or another leaf node:
22
22
 
23
23
  ```ruby
24
24
  class Paragraph < Prosemirror::Nodes::Paragraph
25
25
  def view_template(&)
26
- p(&) unless blank?
26
+ p(&) unless node.blank?(prosemirror_schema)
27
27
  end
28
+ end
29
+ ```
28
30
 
29
- private
31
+ `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. To remove them from the document itself, see the next recipe.
30
32
 
31
- def blank?
32
- node.text_content.strip.empty? && node.content.all? { |child| child.text? || child.type == :hard_break }
33
+ ## Remove empty paragraphs before saving
34
+
35
+ To clean a document before saving it, remove blank paragraphs with `transform`, along with the list items and lists they leave empty:
36
+
37
+ ```ruby
38
+ schema = Prosemirror::Schema.tiptap(case: :camel) # saves `bulletList`, as Tiptap expects
39
+ document = Prosemirror.parse(params[:content], schema:).transform do |node|
40
+ case node
41
+ in { type: :paragraph | :list_item } if node.blank?(schema) then nil
42
+ in { type: :bullet_list | :ordered_list } if node.content.empty? then nil
43
+ else node
33
44
  end
34
45
  end
46
+ record.update!(content: document.to_h)
35
47
  ```
36
48
 
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.
49
+ Without `case: :camel`, `to_h` writes snake_case type names like `bullet_list`, which Tiptap doesn't read. `blank?` gets the schema so that it knows your custom types: an image block isn't blank. Children are transformed first, so the list sees its remaining items. Attributes the schema doesn't declare are kept. When something the schema requires is gone, like the only paragraph of a list item kept by another rule, `transform` adds it back and reports it with `on_invalid`.
38
50
 
39
51
  ## Heading anchors
40
52
 
data/README.md CHANGED
@@ -48,6 +48,32 @@ doc.select { |node| node in { type: :heading, attrs: { level: 1 } } }
48
48
  doc.content.first.text_content
49
49
  ```
50
50
 
51
+ ## Transforming documents
52
+
53
+ `transform` builds a new document from a block called with each node. The block returns the node to keep, another node, an array of nodes to put in its place, or nil to remove it:
54
+
55
+ ```ruby
56
+ doc = doc.transform do |node|
57
+ case node
58
+ in { type: :paragraph } if node.blank? then nil
59
+ in { type: :heading, attrs: { level: 1 } } then node.with(attrs: { level: 2 })
60
+ in { type: :bullet_list } then node.content.flat_map(&:content) # unwrap the list
61
+ else node
62
+ end
63
+ end
64
+ ```
65
+
66
+ - Children are transformed first: the block sees a node with its transformed content, and the nodes it returns aren't transformed again.
67
+ - Nodes are immutable: `with` returns a copy with some properties changed, like `content`, `attrs`, `marks` or `text`. Attributes the schema doesn't declare are kept.
68
+ - `blank?` is true for nodes that show nothing: no text but whitespace, and no leaf nodes but hard breaks.
69
+ - Adjacent text nodes with the same marks are joined. When a node's content changed and lacks what its type requires, like the only paragraph of a list item, the required nodes are added and reported with `on_invalid`.
70
+
71
+ `check` raises `Prosemirror::InvalidDocument` for the first problem with the schema, with its path: an unknown type, an invalid attribute, content that doesn't match a [content expression](TYPES.md#content-expressions), or marks a node doesn't allow. `valid?` returns true or false. Rendering doesn't need a valid document.
72
+
73
+ ```ruby
74
+ doc.check # raises "doc.content[0]: invalid content for blockquote: paragraph text"
75
+ ```
76
+
51
77
  ## HTML import
52
78
 
53
79
  HTML import uses [Herb](https://herb-tools.dev)'s parser, which never raises on malformed HTML. Add it to your Gemfile:
data/ROADMAP.md CHANGED
@@ -177,7 +177,7 @@ They differ in more than names:
177
177
  - Raw HTML in Markdown goes through the HTML importer if Herb is installed.
178
178
  - `Prosemirror.from_text`: blank lines make paragraphs, line breaks make hard breaks.
179
179
 
180
- ### 0.4: Working with documents
180
+ ### 0.4: Working with documents (released)
181
181
 
182
182
  - Transformations that return a new document: rewrite links, unwrap lists, split paragraphs, remove empty ones.
183
183
 
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Checks a document against its schema, as ProseMirror's `Node#check`, and raises InvalidDocument for the
5
+ # first problem, with its path. See Document#check.
6
+ #
7
+ # Rendering and JSON parsing don't need valid content: they render what they get. This checks what
8
+ # they don't: node and mark types, attributes, content expressions, and the marks each node allows.
9
+ class Checker
10
+ def initialize(schema)
11
+ @schema = schema
12
+ end
13
+
14
+ def call(root)
15
+ name = @schema.json_type_name(root.type)
16
+ invalid("the top node is #{name}, not #{@schema.top_node}", name) unless root.type == @schema.top_node
17
+ check(root, name)
18
+ end
19
+
20
+ private
21
+
22
+ def check(node, path)
23
+ type = @schema.node_type(node.type) or invalid("unknown node type #{@schema.json_type_name(node.type)}", path)
24
+ return check_text(node, path) if node.text?
25
+
26
+ check_attrs(type, node.attrs, path)
27
+ node.content.each_with_index do |child, index|
28
+ @schema.node_type(child.type) or invalid("unknown node type #{@schema.json_type_name(child.type)}", "#{path}.content[#{index}]")
29
+ end
30
+ check_content(type, node, path)
31
+ node.content.each_with_index do |child, index|
32
+ child_path = "#{path}.content[#{index}]"
33
+ check_marks(type, child, child_path)
34
+ check(child, child_path)
35
+ end
36
+ end
37
+
38
+ def check_text(node, path)
39
+ invalid("empty text node", path) if node.text.to_s.empty?
40
+ invalid("text nodes have no content", path) unless node.content.empty?
41
+ end
42
+
43
+ def check_content(type, node, path)
44
+ if type.leaf?
45
+ invalid("#{name(type)} has no content, but has #{node.content.size} nodes", path) unless node.content.empty?
46
+ return
47
+ end
48
+
49
+ types = node.content.map { |child| @schema.node_type(child.type) }
50
+ return if @schema.content_match(type).match_types(types)&.valid_end?
51
+
52
+ found = node.content.map { |child| @schema.json_type_name(child.type) }.join(" ")
53
+ invalid("invalid content for #{name(type)}: #{found.empty? ? "nothing" : found}", path)
54
+ end
55
+
56
+ def check_marks(parent, node, path)
57
+ node.marks.each_with_index do |mark, index|
58
+ mark_type = @schema.mark_type(mark.type) or invalid("unknown mark type #{@schema.json_type_name(mark.type)}", "#{path}.marks[#{index}]")
59
+ check_attrs(mark_type, mark.attrs, "#{path}.marks[#{index}]")
60
+ invalid("#{name(parent)} doesn't allow #{name(mark_type)} marks", path) unless parent.allows_mark?(mark_type, @schema)
61
+ end
62
+ sorted = node.marks.reduce([]) { |set, mark| @schema.add_mark(mark, set) }
63
+ invalid("invalid marks: #{node.marks.map { |mark| @schema.json_type_name(mark.type) }.join(" ")}", path) unless sorted == node.marks
64
+ end
65
+
66
+ # Attributes are valid when building them from themselves reports nothing and changes nothing.
67
+ def check_attrs(type, attrs, path)
68
+ problems = []
69
+ built = type.build_attrs(attrs, report: ->(message) { problems << message })
70
+ invalid(problems.first, path) unless problems.empty?
71
+ invalid("invalid attributes: #{attrs.inspect}", path) unless built == attrs
72
+ end
73
+
74
+ def name(type)
75
+ @schema.json_type_name(type.type_name)
76
+ end
77
+
78
+ def invalid(message, path)
79
+ raise InvalidDocument.new(message, path: path)
80
+ end
81
+ end
82
+ end
@@ -64,6 +64,38 @@ module Prosemirror
64
64
  root.text_content
65
65
  end
66
66
 
67
+ # A new document built from a block called with each node, children first. The block returns the node
68
+ # to keep, another node, an array of nodes to put in its place, or nil to remove it:
69
+ #
70
+ # doc.transform do |node|
71
+ # case node
72
+ # in { type: :paragraph } if node.blank? then nil
73
+ # in { type: :heading, attrs: { level: 1 } } then node.with(attrs: { level: 2 })
74
+ # else node
75
+ # end
76
+ # end
77
+ #
78
+ # Adjacent text nodes with the same marks are joined. When a node's content changed and lacks what its
79
+ # content expression requires, the required nodes are added and reported with `on_invalid`.
80
+ def transform(&)
81
+ Document.new(Transformer.new(schema, &).call(root), schema: schema)
82
+ end
83
+
84
+ # Raises InvalidDocument for the first problem with the schema: an unknown node or mark type, invalid
85
+ # attributes, content that doesn't match a content expression, or marks a node doesn't allow. Returns
86
+ # the document.
87
+ def check
88
+ Checker.new(schema).call(root)
89
+ self
90
+ end
91
+
92
+ def valid?
93
+ check
94
+ true
95
+ rescue InvalidDocument
96
+ false
97
+ end
98
+
67
99
  def ==(other)
68
100
  other.is_a?(Document) && root == other.root
69
101
  end
@@ -11,6 +11,9 @@ module Prosemirror
11
11
  # in JSON (for example from a Tiptap extension the schema doesn't know): they are never rendered,
12
12
  # and `to_h` gives them back unchanged.
13
13
  class Node < Literal::Data
14
+ # Whitespace, including non-breaking spaces.
15
+ BLANK = /\A[[:space:]]*\z/
16
+
14
17
  prop :type, Symbol, &Coerce::SYMBOL
15
18
  prop :attrs, _Hash(Symbol, _Any?), default: {}.freeze, &Coerce::FROZEN
16
19
  prop :unknown_attrs, _Hash(String, _Any?), default: {}.freeze, &Coerce::FROZEN
@@ -70,6 +73,18 @@ module Prosemirror
70
73
  end
71
74
  end
72
75
 
76
+ # Whether the node shows nothing: no text but whitespace, and no leaf nodes but line breaks, like an
77
+ # empty paragraph, or a list item holding one. The schema says which nodes are leaves: an image isn't
78
+ # blank, and neither is a node of a type it doesn't know.
79
+ def blank?(schema = Prosemirror.schema)
80
+ [self, *descendants].none? do |node|
81
+ next !BLANK.match?(node.text) if node.text?
82
+
83
+ type = schema.node_type(node.type)
84
+ type.nil? || (type.leaf? && !type.linebreak_replacement?)
85
+ end
86
+ end
87
+
73
88
  def same_markup?(other)
74
89
  @type == other.type && @attrs == other.attrs && @unknown_attrs == other.unknown_attrs && @marks == other.marks
75
90
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Builds a new document from a block called with each node, children first. See Document#transform.
5
+ #
6
+ # The block returns the node to keep, another node, an array of nodes to put in its place, or nil to
7
+ # remove it. Adjacent text nodes with the same marks are then joined, and empty ones removed. When a
8
+ # node's content changed and no longer has what its content expression requires, the required nodes
9
+ # are added, as an empty paragraph in an empty list item, and reported. Other problems are left for
10
+ # Document#check.
11
+ class Transformer
12
+ def initialize(schema, &block)
13
+ raise ArgumentError, "transform needs a block" unless block
14
+
15
+ @schema = schema
16
+ @block = block
17
+ end
18
+
19
+ # The root, with its transformed content. The root itself isn't given to the block.
20
+ def call(root)
21
+ path = @schema.json_type_name(root.type)
22
+ filled(root, root.with(content: transform_content(root, path)), path)
23
+ end
24
+
25
+ private
26
+
27
+ def transform_content(node, path)
28
+ content = node.content.each_with_index.flat_map { |child, index| transform(child, "#{path}.content[#{index}]") }
29
+ Node.join_text(content.reject { |child| child.text? && child.text.empty? })
30
+ end
31
+
32
+ # The nodes the block gives for a node, after its content, with their required content added: the block
33
+ # sees an emptied list item, and can remove it.
34
+ def transform(node, path)
35
+ changed = node.content.empty? ? node : node.with(content: transform_content(node, path))
36
+ nodes = case (result = @block.call(changed))
37
+ when nil then []
38
+ when Node then [result]
39
+ when Array then result
40
+ end
41
+ raise ArgumentError, "the transform block returns a node, an array of nodes or nil, not #{result.inspect}" unless nodes&.all?(Node)
42
+
43
+ nodes.map { |result_node| filled(node, result_node, path) }
44
+ end
45
+
46
+ # The node with its required content added, when its content changed.
47
+ def filled(original, node, path)
48
+ return node if node.content.equal?(original.content) || node.content == original.content
49
+
50
+ content = fill(node, node.content, path)
51
+ content.equal?(node.content) ? node : node.with(content: content)
52
+ end
53
+
54
+ # The content with the nodes its type requires added in front of it, or after it, when they can be.
55
+ def fill(node, content, path)
56
+ type = @schema.node_type(node.type)
57
+ types = content.map { |child| @schema.node_type(child.type) }
58
+ return content if type.nil? || type.leaf? || types.include?(nil)
59
+
60
+ match = @schema.content_match(type)
61
+ return content if match.match_types(types)&.valid_end?
62
+
63
+ if (before = match.fill_before(types, @schema, to_end: true))
64
+ added = created(before, node, path) and return added + content
65
+ elsif (after = match.match_types(types)&.fill_before([], @schema, to_end: true))
66
+ added = created(after, node, path) and return content + added
67
+ end
68
+ content
69
+ end
70
+
71
+ # Empty nodes of these types, filled with what they require, or nil when one can't be created.
72
+ def created(types, node, path)
73
+ nodes = types.map { |type| @schema.create_and_fill(type) }
74
+ return nil if nodes.include?(nil)
75
+
76
+ names = types.map { |type| @schema.json_type_name(type.type_name) }.join(", ")
77
+ @schema.report("added required #{names} to #{@schema.json_type_name(node.type)}", path: path)
78
+ nodes
79
+ end
80
+ end
81
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prosemirror
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prosemirror
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans Lemuet
@@ -66,6 +66,7 @@ files:
66
66
  - ROADMAP.md
67
67
  - TYPES.md
68
68
  - lib/prosemirror.rb
69
+ - lib/prosemirror/checker.rb
69
70
  - lib/prosemirror/coerce.rb
70
71
  - lib/prosemirror/content_expression.rb
71
72
  - lib/prosemirror/content_match.rb
@@ -122,6 +123,7 @@ files:
122
123
  - lib/prosemirror/rendering.rb
123
124
  - lib/prosemirror/schema.rb
124
125
  - lib/prosemirror/text_serializer.rb
126
+ - lib/prosemirror/transformer.rb
125
127
  - lib/prosemirror/type_definition.rb
126
128
  - lib/prosemirror/utf8.rb
127
129
  - lib/prosemirror/version.rb