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 +4 -4
- data/CHANGELOG.md +6 -0
- data/COOKBOOK.md +18 -6
- data/README.md +26 -0
- data/ROADMAP.md +1 -1
- data/lib/prosemirror/checker.rb +82 -0
- data/lib/prosemirror/document.rb +32 -0
- data/lib/prosemirror/node.rb +15 -0
- data/lib/prosemirror/transformer.rb +81 -0
- data/lib/prosemirror/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c84fca02d8a8c1f32c9162da3a22133f2ef774c035281331d7d92423d24a242f
|
|
4
|
+
data.tar.gz: ca2ec139725747838a359f43a667c1fc4b5b5249b7dba12775ffbe39985f0c38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
`
|
|
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
|
data/lib/prosemirror/document.rb
CHANGED
|
@@ -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
|
data/lib/prosemirror/node.rb
CHANGED
|
@@ -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
|
data/lib/prosemirror/version.rb
CHANGED
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.
|
|
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
|