prose_mirror_ruby 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +147 -0
- data/lib/prose_mirror/converter.rb +52 -0
- data/lib/prose_mirror/mark.rb +30 -0
- data/lib/prose_mirror/node.rb +82 -0
- data/lib/prose_mirror/serializers/markdown_serializer.rb +468 -0
- data/lib/prose_mirror/version.rb +3 -0
- data/lib/prose_mirror.rb +34 -0
- metadata +84 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 9ca1e1715fe8d1e412d15a8ffddb367b6bd3e648029e62372051f343adcf0172
|
4
|
+
data.tar.gz: d5e675c75e8acb88e347d1f38ee30f157d63310d886883ea838ed96e52c9b4c1
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b02364d8907439c6ab5cab1a1c829bd8f5c437300dc72a93c81786e54a32bf0ede115a63280441fe58a2c3effeb973eb91e997e3359bae2e572e4fc1192886d0
|
7
|
+
data.tar.gz: 80ccdbcef5ab0e714185707114402727fbb603d431be68c28a2ab58bb4c5bad61640d5b7b4373fefa89a7a4660f07be805f838d03ff9a5670c4201878f70c978
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
|
+
|
8
|
+
## [0.1.0] - 2023-06-15
|
9
|
+
|
10
|
+
### Added
|
11
|
+
- Initial release
|
12
|
+
- Core ProseMirror document model (Node, Mark)
|
13
|
+
- JSON parser and converter
|
14
|
+
- Markdown serialization
|
15
|
+
- Helper methods for traversing and manipulating nodes
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Robert Ross, FireHydrant
|
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 all
|
13
|
+
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 THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,147 @@
|
|
1
|
+
# ProseMirror Ruby
|
2
|
+
|
3
|
+
A Ruby library for working with [ProseMirror](https://prosemirror.net/) documents, including conversion between ProseMirror JSON and other formats like Markdown.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'prose_mirror_ruby'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
```bash
|
16
|
+
$ bundle install
|
17
|
+
```
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
```bash
|
22
|
+
$ gem install prose_mirror_ruby
|
23
|
+
```
|
24
|
+
|
25
|
+
## Features
|
26
|
+
|
27
|
+
- Parse ProseMirror JSON documents into Ruby objects
|
28
|
+
- Convert ProseMirror documents to Markdown
|
29
|
+
- Traverse and manipulate document nodes
|
30
|
+
- Apply marks (like strong, em, code, etc.) to text nodes
|
31
|
+
|
32
|
+
## Usage
|
33
|
+
|
34
|
+
### Parsing ProseMirror JSON
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
require 'prose_mirror'
|
38
|
+
|
39
|
+
# Example ProseMirror JSON document
|
40
|
+
json_document = <<~JSON
|
41
|
+
{
|
42
|
+
"type": "doc",
|
43
|
+
"content": [
|
44
|
+
{
|
45
|
+
"type": "heading",
|
46
|
+
"attrs": { "level": 2 },
|
47
|
+
"content": [
|
48
|
+
{ "type": "text", "text": "Example Document" }
|
49
|
+
]
|
50
|
+
},
|
51
|
+
{
|
52
|
+
"type": "paragraph",
|
53
|
+
"content": [
|
54
|
+
{
|
55
|
+
"type": "text",
|
56
|
+
"text": "This is bold text",
|
57
|
+
"marks": [
|
58
|
+
{ "type": "strong" }
|
59
|
+
]
|
60
|
+
},
|
61
|
+
{ "type": "text", "text": " and " },
|
62
|
+
{
|
63
|
+
"type": "text",
|
64
|
+
"text": "this is italic",
|
65
|
+
"marks": [
|
66
|
+
{ "type": "em" }
|
67
|
+
]
|
68
|
+
},
|
69
|
+
{ "type": "text", "text": "." }
|
70
|
+
]
|
71
|
+
}
|
72
|
+
]
|
73
|
+
}
|
74
|
+
JSON
|
75
|
+
|
76
|
+
# Parse JSON into a Node object tree
|
77
|
+
document = ProseMirror.parse(json_document)
|
78
|
+
```
|
79
|
+
|
80
|
+
### Converting to Markdown
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
# Convert a document to Markdown
|
84
|
+
markdown = ProseMirror.to_markdown(document)
|
85
|
+
puts markdown
|
86
|
+
```
|
87
|
+
|
88
|
+
### Working with Nodes
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
# Traverse the document and print text nodes
|
92
|
+
def traverse_and_print(node, level = 0)
|
93
|
+
indent = " " * level
|
94
|
+
|
95
|
+
if node.is_text
|
96
|
+
puts "#{indent}Text: #{node.text}"
|
97
|
+
else
|
98
|
+
puts "#{indent}Node: #{node.type.name}"
|
99
|
+
|
100
|
+
node.each_with_index do |child, i|
|
101
|
+
traverse_and_print(child, level + 1)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
traverse_and_print(document)
|
107
|
+
```
|
108
|
+
|
109
|
+
### Custom Markdown Serialization
|
110
|
+
|
111
|
+
You can customize the Markdown serialization by providing your own node and mark serializers:
|
112
|
+
|
113
|
+
```ruby
|
114
|
+
# Add a custom serializer for a new node type
|
115
|
+
custom_node_serializers = ProseMirror::Serializers::MarkdownSerializer::DEFAULT_NODE_SERIALIZERS.dup
|
116
|
+
custom_node_serializers[:custom_node] = ->(state, node, parent = nil, index = nil) {
|
117
|
+
state.write("CUSTOM NODE: #{node.attrs[:custom_data]}")
|
118
|
+
state.close_block(node)
|
119
|
+
}
|
120
|
+
|
121
|
+
# Create a serializer with custom node handlers
|
122
|
+
serializer = ProseMirror::Serializers::MarkdownSerializer.new(
|
123
|
+
custom_node_serializers,
|
124
|
+
ProseMirror::Serializers::MarkdownSerializer::DEFAULT_MARK_SERIALIZERS
|
125
|
+
)
|
126
|
+
|
127
|
+
# Use the custom serializer
|
128
|
+
markdown = serializer.serialize(document)
|
129
|
+
```
|
130
|
+
|
131
|
+
## Development
|
132
|
+
|
133
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
134
|
+
|
135
|
+
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).
|
136
|
+
|
137
|
+
## Contributing
|
138
|
+
|
139
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/firehydrant/prose_mirror.
|
140
|
+
|
141
|
+
## Known Issues
|
142
|
+
|
143
|
+
- Markdown serialization for lists and code blocks may not produce perfect output in all cases. Contributions to improve this are welcome.
|
144
|
+
|
145
|
+
## License
|
146
|
+
|
147
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module ProseMirror
|
2
|
+
# Converter for transforming between ProseMirror JSON and Node objects
|
3
|
+
class Converter
|
4
|
+
# Convert a JSON ProseMirror document into Ruby Node objects
|
5
|
+
# @param json_string [String] The JSON string representation of a ProseMirror document
|
6
|
+
# @return [Node] The root node of the document
|
7
|
+
def self.from_json(json_string)
|
8
|
+
data = JSON.parse(json_string)
|
9
|
+
parse_node(data)
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
# Recursively parse nodes from JSON data
|
15
|
+
# @param node_data [Hash] The node data from JSON
|
16
|
+
# @return [Node] The parsed node
|
17
|
+
def self.parse_node(node_data)
|
18
|
+
type = node_data["type"]
|
19
|
+
attrs = parse_attrs(node_data["attrs"] || {})
|
20
|
+
marks = parse_marks(node_data["marks"] || [])
|
21
|
+
|
22
|
+
if type == "text"
|
23
|
+
# Text nodes have text content directly
|
24
|
+
Node.new(type, attrs, [], marks, node_data["text"])
|
25
|
+
else
|
26
|
+
# Non-text nodes have child content
|
27
|
+
content = (node_data["content"] || []).map { |child| parse_node(child) }
|
28
|
+
Node.new(type, attrs, content, marks)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Parse attributes from JSON to Ruby hash with symbol keys
|
33
|
+
# @param attrs_data [Hash] The attributes data from JSON
|
34
|
+
# @return [Hash] Hash with symbol keys
|
35
|
+
def self.parse_attrs(attrs_data)
|
36
|
+
result = {}
|
37
|
+
attrs_data.each do |key, value|
|
38
|
+
result[key.to_sym] = value
|
39
|
+
end
|
40
|
+
result
|
41
|
+
end
|
42
|
+
|
43
|
+
# Parse marks from JSON to Ruby Mark objects
|
44
|
+
# @param marks_data [Array] The marks data array from JSON
|
45
|
+
# @return [Array<Mark>] Array of Mark objects
|
46
|
+
def self.parse_marks(marks_data)
|
47
|
+
marks_data.map do |mark_data|
|
48
|
+
Mark.new(mark_data["type"], parse_attrs(mark_data["attrs"] || {}))
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module ProseMirror
|
2
|
+
# Represents a mark in a ProseMirror document
|
3
|
+
# Marks can be applied to nodes to indicate formatting or other attributes
|
4
|
+
class Mark
|
5
|
+
attr_reader :type, :attrs
|
6
|
+
|
7
|
+
# Create a new Mark
|
8
|
+
# @param type [String] The mark type name (e.g., "strong", "em", "link")
|
9
|
+
# @param attrs [Hash] Mark attributes
|
10
|
+
def initialize(type, attrs = {})
|
11
|
+
@type = OpenStruct.new(name: type)
|
12
|
+
@attrs = attrs
|
13
|
+
end
|
14
|
+
|
15
|
+
# Check if this mark is equal to another mark
|
16
|
+
# Two marks are equal if they have the same type name
|
17
|
+
# @param other [Mark] The other mark to compare with
|
18
|
+
# @return [Boolean] true if the marks are equal, false otherwise
|
19
|
+
def eq(other)
|
20
|
+
@type.name == other.type.name
|
21
|
+
end
|
22
|
+
|
23
|
+
# Check if this mark is in the given set of marks
|
24
|
+
# @param mark_array [Array<Mark>] The array of marks to check against
|
25
|
+
# @return [Boolean] true if the mark is in the set, false otherwise
|
26
|
+
def is_in_set(mark_array)
|
27
|
+
mark_array.any? { |m| eq(m) }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module ProseMirror
|
2
|
+
# Represents a node in a ProseMirror document
|
3
|
+
# This can be a block node (paragraph, heading), inline node, or a text node
|
4
|
+
class Node
|
5
|
+
attr_reader :type, :attrs, :content, :marks, :text
|
6
|
+
|
7
|
+
# Create a new Node
|
8
|
+
# @param type [String] The node type name
|
9
|
+
# @param attrs [Hash] Node attributes
|
10
|
+
# @param content [Array<Node>] Child nodes
|
11
|
+
# @param marks [Array<Mark>] Node marks
|
12
|
+
# @param text [String, nil] Text content for text nodes, nil otherwise
|
13
|
+
def initialize(type, attrs = {}, content = [], marks = [], text = nil)
|
14
|
+
@type = OpenStruct.new(
|
15
|
+
name: type,
|
16
|
+
is_block: true,
|
17
|
+
is_leaf: content.empty? && !text,
|
18
|
+
inline_content: !!text
|
19
|
+
)
|
20
|
+
@attrs = attrs
|
21
|
+
@content = content
|
22
|
+
@marks = marks
|
23
|
+
@text = text
|
24
|
+
end
|
25
|
+
|
26
|
+
# Check if this is a text node
|
27
|
+
# @return [Boolean] true if this is a text node, false otherwise
|
28
|
+
def is_text
|
29
|
+
!@text.nil?
|
30
|
+
end
|
31
|
+
|
32
|
+
# Check if this is a block node
|
33
|
+
# @return [Boolean] true if this is a block node, false otherwise
|
34
|
+
def is_block
|
35
|
+
@type.is_block
|
36
|
+
end
|
37
|
+
|
38
|
+
# Get the text content of this node
|
39
|
+
# For text nodes, returns the text directly
|
40
|
+
# For non-text nodes, returns the concatenated text content of all child nodes
|
41
|
+
# @return [String] The text content
|
42
|
+
def text_content
|
43
|
+
is_text ? @text : @content.map(&:text_content).join("")
|
44
|
+
end
|
45
|
+
|
46
|
+
# Get the number of child nodes
|
47
|
+
# @return [Integer] The number of child nodes
|
48
|
+
def child_count
|
49
|
+
@content.length
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get a child node at the specified index
|
53
|
+
# @param index [Integer] The index of the child node to retrieve
|
54
|
+
# @return [Node, nil] The child node, or nil if not found
|
55
|
+
def child(index)
|
56
|
+
@content[index]
|
57
|
+
end
|
58
|
+
|
59
|
+
# Get the size of this node
|
60
|
+
# For text nodes, returns the text length
|
61
|
+
# For other nodes, returns 1
|
62
|
+
# @return [Integer] The node size
|
63
|
+
def node_size
|
64
|
+
@text ? @text.length : 1
|
65
|
+
end
|
66
|
+
|
67
|
+
# Iterate over each child node with its index
|
68
|
+
# @yield [node, index] Yields each child node and its index
|
69
|
+
# @yieldparam node [Node] The child node
|
70
|
+
# @yieldparam index [Integer] The index of the child node
|
71
|
+
def each_with_index(&block)
|
72
|
+
@content.each_with_index(&block)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Create a new text node with the given text but same marks
|
76
|
+
# @param new_text [String] The new text content
|
77
|
+
# @return [Node] A new text node with the same marks but different text
|
78
|
+
def with_text(new_text)
|
79
|
+
Node.new(@type.name, @attrs, [], @marks, new_text)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,468 @@
|
|
1
|
+
module ProseMirror
|
2
|
+
module Serializers
|
3
|
+
# A blank mark used as a fallback
|
4
|
+
BLANK_MARK = {open: "", close: "", mixable: true}.freeze
|
5
|
+
|
6
|
+
# A specification for serializing a ProseMirror document as Markdown/CommonMark text.
|
7
|
+
class MarkdownSerializer
|
8
|
+
attr_reader :nodes, :marks, :options
|
9
|
+
|
10
|
+
# Default serializers for various node types
|
11
|
+
DEFAULT_NODE_SERIALIZERS = {
|
12
|
+
blockquote: ->(state, node, parent = nil, index = nil) {
|
13
|
+
state.wrap_block("> ", nil, node) { state.render_content(node) }
|
14
|
+
},
|
15
|
+
|
16
|
+
code_block: ->(state, node, parent = nil, index = nil) {
|
17
|
+
# Make sure fence is longer than any dash sequence within content
|
18
|
+
backticks = node.text_content.scan(/`{3,}/m)
|
19
|
+
fence = backticks.empty? ? "```" : (backticks.sort.last + "`")
|
20
|
+
|
21
|
+
state.write(fence + (node.attrs[:params] || "") + "\n")
|
22
|
+
state.text(node.text_content, false)
|
23
|
+
# Add newline before closing marker
|
24
|
+
state.write("\n")
|
25
|
+
state.write(fence)
|
26
|
+
state.close_block(node)
|
27
|
+
},
|
28
|
+
|
29
|
+
heading: ->(state, node, parent = nil, index = nil) {
|
30
|
+
state.write(state.repeat("#", node.attrs[:level]) + " ")
|
31
|
+
state.render_inline(node, false)
|
32
|
+
state.close_block(node)
|
33
|
+
},
|
34
|
+
|
35
|
+
horizontal_rule: ->(state, node, parent = nil, index = nil) {
|
36
|
+
state.write(node.attrs[:markup] || "---")
|
37
|
+
state.close_block(node)
|
38
|
+
},
|
39
|
+
|
40
|
+
bullet_list: ->(state, node, parent = nil, index = nil) {
|
41
|
+
state.render_list(node, " ", ->(_) { (node.attrs[:bullet] || "*") + " " })
|
42
|
+
},
|
43
|
+
|
44
|
+
ordered_list: ->(state, node, parent = nil, index = nil) {
|
45
|
+
start = node.attrs[:order] || 1
|
46
|
+
max_w = (start + node.child_count - 1).to_s.length
|
47
|
+
space = state.repeat(" ", max_w + 2)
|
48
|
+
|
49
|
+
state.render_list(node, space, ->(i) {
|
50
|
+
n_str = (start + i).to_s
|
51
|
+
state.repeat(" ", max_w - n_str.length) + n_str + ". "
|
52
|
+
})
|
53
|
+
},
|
54
|
+
|
55
|
+
list_item: ->(state, node, parent = nil, index = nil) {
|
56
|
+
state.render_content(node)
|
57
|
+
},
|
58
|
+
|
59
|
+
paragraph: ->(state, node, parent = nil, index = nil) {
|
60
|
+
state.render_inline(node)
|
61
|
+
state.close_block(node)
|
62
|
+
},
|
63
|
+
|
64
|
+
image: ->(state, node, parent = nil, index = nil) {
|
65
|
+
state.write("![" + state.esc(node.attrs[:alt] || "") + "](" +
|
66
|
+
node.attrs[:src].gsub(/[\(\)]/, "\\\\\\&") +
|
67
|
+
(node.attrs[:title] ? ' "' + node.attrs[:title].gsub('"', '\\"') + '"' : "") +
|
68
|
+
")")
|
69
|
+
},
|
70
|
+
|
71
|
+
hard_break: ->(state, node, parent, index) {
|
72
|
+
(index + 1...parent.child_count).each do |i|
|
73
|
+
if parent.child(i).type != node.type
|
74
|
+
state.write("\\\n")
|
75
|
+
return
|
76
|
+
end
|
77
|
+
end
|
78
|
+
},
|
79
|
+
|
80
|
+
text: ->(state, node, parent = nil, index = nil) {
|
81
|
+
state.text(node.text, !state.in_autolink)
|
82
|
+
}
|
83
|
+
}
|
84
|
+
|
85
|
+
# Default serializers for various mark types
|
86
|
+
DEFAULT_MARK_SERIALIZERS = {
|
87
|
+
em: {
|
88
|
+
open: "*",
|
89
|
+
close: "*",
|
90
|
+
mixable: true,
|
91
|
+
expel_enclosing_whitespace: true
|
92
|
+
},
|
93
|
+
|
94
|
+
strong: {
|
95
|
+
open: "**",
|
96
|
+
close: "**",
|
97
|
+
mixable: true,
|
98
|
+
expel_enclosing_whitespace: true
|
99
|
+
},
|
100
|
+
|
101
|
+
link: {
|
102
|
+
open: ->(state, mark, parent, index) {
|
103
|
+
state.in_autolink = ProseMirror::Serializers.is_plain_url(mark, parent, index)
|
104
|
+
state.in_autolink ? "<" : "["
|
105
|
+
},
|
106
|
+
close: ->(state, mark, parent, index) {
|
107
|
+
in_autolink = state.in_autolink
|
108
|
+
state.in_autolink = nil
|
109
|
+
|
110
|
+
if in_autolink
|
111
|
+
">"
|
112
|
+
else
|
113
|
+
"](" + mark.attrs[:href].gsub(/[\(\)"]/, "\\\\\\&") +
|
114
|
+
(mark.attrs[:title] ? ' "' + mark.attrs[:title].gsub('"', '\\"') + '"' : "") + ")"
|
115
|
+
end
|
116
|
+
},
|
117
|
+
mixable: true
|
118
|
+
},
|
119
|
+
|
120
|
+
code: {
|
121
|
+
open: ->(_, mark, parent, index) { ProseMirror::Serializers.backticks_for(parent.child(index), -1) },
|
122
|
+
close: ->(_, mark, parent, index) { ProseMirror::Serializers.backticks_for(parent.child(index - 1), 1) },
|
123
|
+
escape: false
|
124
|
+
}
|
125
|
+
}
|
126
|
+
|
127
|
+
# Constructor with node serializers, mark serializers, and options
|
128
|
+
# @param nodes [Hash] Node serializer functions
|
129
|
+
# @param marks [Hash] Mark serializer specifications
|
130
|
+
# @param options [Hash] Configuration options
|
131
|
+
def initialize(nodes, marks, options = {})
|
132
|
+
@nodes = nodes
|
133
|
+
@marks = marks
|
134
|
+
@options = options
|
135
|
+
end
|
136
|
+
|
137
|
+
# Serialize the content of the given node to CommonMark
|
138
|
+
# @param content [Node] The node to serialize
|
139
|
+
# @param options [Hash] Serialization options
|
140
|
+
# @return [String] The markdown output
|
141
|
+
def serialize(content, options = {})
|
142
|
+
options = @options.merge(options)
|
143
|
+
state = MarkdownSerializerState.new(@nodes, @marks, options)
|
144
|
+
state.render_content(content)
|
145
|
+
state.out
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Helper method for determining backticks for code marks
|
150
|
+
def self.backticks_for(node, side)
|
151
|
+
ticks = /`+/
|
152
|
+
len = 0
|
153
|
+
|
154
|
+
if node.is_text
|
155
|
+
node.text.scan(ticks) { |m| len = [len, m.length].max }
|
156
|
+
end
|
157
|
+
|
158
|
+
result = (len > 0 && side > 0) ? " `" : "`"
|
159
|
+
len.times { result += "`" }
|
160
|
+
result += " " if len > 0 && side < 0
|
161
|
+
|
162
|
+
result
|
163
|
+
end
|
164
|
+
|
165
|
+
# Determines if a link is a plain URL
|
166
|
+
def self.is_plain_url(link, parent, index)
|
167
|
+
return false if link.attrs[:title] || !/^\w+:/.match?(link.attrs[:href])
|
168
|
+
|
169
|
+
content = parent.child(index)
|
170
|
+
return false if !content.is_text || content.text != link.attrs[:href] || content.marks[content.marks.length - 1] != link
|
171
|
+
|
172
|
+
index == parent.child_count - 1 || !link.is_in_set(parent.child(index + 1).marks)
|
173
|
+
end
|
174
|
+
|
175
|
+
# This class is used to track state and expose methods related to markdown serialization
|
176
|
+
class MarkdownSerializerState
|
177
|
+
attr_accessor :delim, :out, :closed, :in_autolink, :at_block_start, :in_tight_list
|
178
|
+
attr_reader :nodes, :marks, :options
|
179
|
+
|
180
|
+
# Initialize a new state object
|
181
|
+
def initialize(nodes, marks, options)
|
182
|
+
@nodes = nodes
|
183
|
+
@marks = marks
|
184
|
+
@options = options
|
185
|
+
@delim = ""
|
186
|
+
@out = ""
|
187
|
+
@closed = nil
|
188
|
+
@in_autolink = nil
|
189
|
+
@at_block_start = false
|
190
|
+
@in_tight_list = false
|
191
|
+
|
192
|
+
@options[:tight_lists] = false if @options[:tight_lists].nil?
|
193
|
+
@options[:hard_break_node_name] = "hard_break" if @options[:hard_break_node_name].nil?
|
194
|
+
end
|
195
|
+
|
196
|
+
# Flush any pending closing operations
|
197
|
+
def flush_close(size = 2)
|
198
|
+
if @closed
|
199
|
+
@out += "\n" unless at_blank?
|
200
|
+
if size > 1
|
201
|
+
delim_min = @delim
|
202
|
+
trim = /\s+$/.match(delim_min)
|
203
|
+
delim_min = delim_min[0...delim_min.length - trim[0].length] if trim
|
204
|
+
|
205
|
+
(1...size).each do
|
206
|
+
@out += delim_min + "\n"
|
207
|
+
end
|
208
|
+
end
|
209
|
+
@closed = nil
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Get mark info by name
|
214
|
+
def get_mark(name)
|
215
|
+
info = @marks[name.to_sym]
|
216
|
+
if !info
|
217
|
+
if @options[:strict] != false
|
218
|
+
raise "Mark type `#{name}` not supported by Markdown renderer"
|
219
|
+
end
|
220
|
+
info = BLANK_MARK
|
221
|
+
end
|
222
|
+
info
|
223
|
+
end
|
224
|
+
|
225
|
+
# Wrap a block with delimiters
|
226
|
+
def wrap_block(delim, first_delim, node)
|
227
|
+
old = @delim
|
228
|
+
write(first_delim.nil? ? delim : first_delim)
|
229
|
+
@delim += delim
|
230
|
+
yield
|
231
|
+
@delim = old
|
232
|
+
close_block(node)
|
233
|
+
end
|
234
|
+
|
235
|
+
# Check if the output ends with a blank line
|
236
|
+
def at_blank?
|
237
|
+
/(^|\n)$/.match?(@out)
|
238
|
+
end
|
239
|
+
|
240
|
+
# Ensure the current content ends with a newline
|
241
|
+
def ensure_new_line
|
242
|
+
@out += "\n" unless at_blank?
|
243
|
+
end
|
244
|
+
|
245
|
+
# Write content to the output
|
246
|
+
def write(content = nil)
|
247
|
+
flush_close
|
248
|
+
@out += @delim if @delim && at_blank?
|
249
|
+
@out += content if content
|
250
|
+
end
|
251
|
+
|
252
|
+
# Close the block for the given node
|
253
|
+
def close_block(node)
|
254
|
+
@closed = node
|
255
|
+
end
|
256
|
+
|
257
|
+
# Add text to the document
|
258
|
+
def text(text, escape = true)
|
259
|
+
lines = text.split("\n")
|
260
|
+
lines.each_with_index do |line, i|
|
261
|
+
write
|
262
|
+
|
263
|
+
# Escape exclamation marks in front of links
|
264
|
+
if !escape && line[0] == "[" && /(^|[^\\])!$/.match?(@out)
|
265
|
+
@out = @out[0...@out.length - 1] + "\\!"
|
266
|
+
end
|
267
|
+
|
268
|
+
@out += escape ? esc(line, @at_block_start) : line
|
269
|
+
@out += "\n" if i != lines.length - 1
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Render a node
|
274
|
+
def render(node, parent, index)
|
275
|
+
if @nodes[node.type.name.to_sym]
|
276
|
+
@nodes[node.type.name.to_sym].call(self, node, parent, index)
|
277
|
+
elsif @options[:strict] != false
|
278
|
+
raise "Token type `#{node.type.name}` not supported by Markdown renderer"
|
279
|
+
elsif !node.type.is_leaf
|
280
|
+
if node.type.inline_content
|
281
|
+
render_inline(node)
|
282
|
+
else
|
283
|
+
render_content(node)
|
284
|
+
end
|
285
|
+
close_block(node) if node.is_block
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
# Render the contents of a parent as block nodes
|
290
|
+
def render_content(parent)
|
291
|
+
parent.each_with_index do |node, i|
|
292
|
+
render(node, parent, i)
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
# Render inline content
|
297
|
+
def render_inline(parent, from_block_start = true)
|
298
|
+
@at_block_start = from_block_start
|
299
|
+
active = []
|
300
|
+
trailing = ""
|
301
|
+
|
302
|
+
progress = lambda do |node, offset, index|
|
303
|
+
marks = node ? node.marks : []
|
304
|
+
|
305
|
+
# Remove marks from hard_break that are the last node inside
|
306
|
+
# that mark to prevent parser edge cases
|
307
|
+
if node && node.type.name == @options[:hard_break_node_name]
|
308
|
+
marks = marks.select do |m|
|
309
|
+
next false if index + 1 == parent.child_count
|
310
|
+
|
311
|
+
next_node = parent.child(index + 1)
|
312
|
+
m.is_in_set(next_node.marks) && (!next_node.is_text || /\S/.match?(next_node.text))
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
leading = trailing
|
317
|
+
trailing = ""
|
318
|
+
|
319
|
+
# Handle whitespace expelling
|
320
|
+
if node && node.is_text && marks.any? { |mark|
|
321
|
+
info = get_mark(mark.type.name.to_sym)
|
322
|
+
info && info[:expel_enclosing_whitespace] && !mark.is_in_set(active)
|
323
|
+
}
|
324
|
+
match = /^(\s*)(.*)$/m.match(node.text)
|
325
|
+
if match[1] && !match[1].empty?
|
326
|
+
leading += match[1]
|
327
|
+
node = (match[2] && !match[2].empty?) ? node.with_text(match[2]) : nil
|
328
|
+
marks = active if node.nil?
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
if node && node.is_text && marks.any? { |mark|
|
333
|
+
info = get_mark(mark.type.name.to_sym)
|
334
|
+
info && info[:expel_enclosing_whitespace] &&
|
335
|
+
(index == parent.child_count - 1 || !mark.is_in_set(parent.child(index + 1).marks))
|
336
|
+
}
|
337
|
+
match = /^(.*?)(\s*)$/m.match(node.text)
|
338
|
+
if match[2] && !match[2].empty?
|
339
|
+
trailing = match[2]
|
340
|
+
node = (match[1] && !match[1].empty?) ? node.with_text(match[1]) : nil
|
341
|
+
marks = active if node.nil?
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
inner = (marks.length > 0) ? marks[marks.length - 1] : nil
|
346
|
+
no_esc = inner && get_mark(inner.type.name)[:escape] == false
|
347
|
+
len = marks.length - (no_esc ? 1 : 0)
|
348
|
+
|
349
|
+
# Try to reorder marks to avoid needless mark recalculation
|
350
|
+
i = 0
|
351
|
+
while i < len
|
352
|
+
mark = marks[i]
|
353
|
+
info = get_mark(mark.type.name)
|
354
|
+
break unless info[:mixable]
|
355
|
+
|
356
|
+
j = 0
|
357
|
+
while j < active.length
|
358
|
+
other = active[j]
|
359
|
+
info_other = get_mark(other.type.name)
|
360
|
+
break unless info_other[:mixable]
|
361
|
+
|
362
|
+
if mark.eq(other)
|
363
|
+
if i > j
|
364
|
+
marks = marks[0...j] + [mark] + marks[j...i] + marks[(i + 1)...len]
|
365
|
+
elsif j > i
|
366
|
+
marks = marks[0...i] + marks[(i + 1)...j] + [mark] + marks[j...len]
|
367
|
+
end
|
368
|
+
break
|
369
|
+
end
|
370
|
+
j += 1
|
371
|
+
end
|
372
|
+
i += 1
|
373
|
+
end
|
374
|
+
|
375
|
+
# Find the prefix of the mark set that didn't change
|
376
|
+
keep = 0
|
377
|
+
while keep < [active.length, len].min && active[keep].eq(marks[keep])
|
378
|
+
keep += 1
|
379
|
+
end
|
380
|
+
|
381
|
+
# Close marks that no longer apply
|
382
|
+
(active.length - 1).downto(keep) do |i|
|
383
|
+
info = get_mark(active[i].type.name)
|
384
|
+
text = info[:close]
|
385
|
+
text = text.call(self, active[i], parent, index) if text.is_a?(Proc)
|
386
|
+
write(text)
|
387
|
+
end
|
388
|
+
|
389
|
+
text = leading
|
390
|
+
active.slice!(keep, active.length)
|
391
|
+
|
392
|
+
# Output any previously expelled trailing whitespace
|
393
|
+
if text && leading.length > 0
|
394
|
+
@out += text
|
395
|
+
end
|
396
|
+
|
397
|
+
# Open marks that are new now
|
398
|
+
(keep...len).each do |i|
|
399
|
+
info = get_mark(marks[i].type.name)
|
400
|
+
text = info[:open]
|
401
|
+
text = text.call(self, marks[i], parent, index) if text.is_a?(Proc)
|
402
|
+
write(text)
|
403
|
+
active.push(marks[i])
|
404
|
+
end
|
405
|
+
|
406
|
+
# Render the node
|
407
|
+
if node
|
408
|
+
write
|
409
|
+
if no_esc
|
410
|
+
render(node, parent, index)
|
411
|
+
else
|
412
|
+
text = node.text
|
413
|
+
if node.is_text
|
414
|
+
write(esc(text, @at_block_start))
|
415
|
+
else
|
416
|
+
render(node, parent, index)
|
417
|
+
end
|
418
|
+
end
|
419
|
+
end
|
420
|
+
|
421
|
+
@at_block_start = false
|
422
|
+
end
|
423
|
+
|
424
|
+
(0...parent.child_count).each do |i|
|
425
|
+
progress.call(parent.child(i), 0, i)
|
426
|
+
end
|
427
|
+
progress.call(nil, 0, parent.child_count)
|
428
|
+
end
|
429
|
+
|
430
|
+
# Render a list
|
431
|
+
def render_list(node, delim, first_delim)
|
432
|
+
if @closed && @closed.type == node.type
|
433
|
+
@closed = nil
|
434
|
+
else
|
435
|
+
ensure_new_line
|
436
|
+
end
|
437
|
+
|
438
|
+
starting = @delim
|
439
|
+
|
440
|
+
node.each_with_index do |child, i|
|
441
|
+
old_tight = @in_tight_list
|
442
|
+
@in_tight_list = node.attrs[:tight]
|
443
|
+
@delim = starting + first_delim.call(i)
|
444
|
+
render(child, node, i)
|
445
|
+
@in_tight_list = old_tight
|
446
|
+
end
|
447
|
+
|
448
|
+
size = (@closed && @closed.type.name == "paragraph" && !node.attrs[:tight]) ? 2 : 1
|
449
|
+
flush_close(size)
|
450
|
+
end
|
451
|
+
|
452
|
+
# Escape Markdown characters
|
453
|
+
def esc(str, start_of_line = false)
|
454
|
+
str = str.gsub(/[`*\\~\[\]_]/) { |m| "\\" + m }
|
455
|
+
if start_of_line
|
456
|
+
str = str.gsub(/^[#\-*+>]/) { |m| "\\" + m }
|
457
|
+
.gsub(/^(\d+)\./) { |_, d| d + "\\." }
|
458
|
+
end
|
459
|
+
str.gsub("![", "\\![")
|
460
|
+
end
|
461
|
+
|
462
|
+
# Repeat a string n times
|
463
|
+
def repeat(str, n)
|
464
|
+
str * n
|
465
|
+
end
|
466
|
+
end
|
467
|
+
end
|
468
|
+
end
|
data/lib/prose_mirror.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
require "json"
|
2
|
+
require "ostruct"
|
3
|
+
|
4
|
+
# Require all ProseMirror components
|
5
|
+
require_relative "prose_mirror/version"
|
6
|
+
require_relative "prose_mirror/node"
|
7
|
+
require_relative "prose_mirror/mark"
|
8
|
+
require_relative "prose_mirror/converter"
|
9
|
+
require_relative "prose_mirror/serializers/markdown_serializer"
|
10
|
+
|
11
|
+
# ProseMirror module serves as the namespace for all ProseMirror components
|
12
|
+
module ProseMirror
|
13
|
+
# Helper functions can be added here if needed
|
14
|
+
|
15
|
+
# Convert a JSON ProseMirror document to a ProseMirror::Node tree
|
16
|
+
# @param json_string [String] The JSON string representation of a ProseMirror document
|
17
|
+
# @return [ProseMirror::Node] The root node of the document
|
18
|
+
def self.parse(json_string)
|
19
|
+
Converter.from_json(json_string)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Convert a ProseMirror::Node to Markdown
|
23
|
+
# @param node [ProseMirror::Node] The root node of the document
|
24
|
+
# @param options [Hash] Optional serialization options
|
25
|
+
# @return [String] The markdown representation
|
26
|
+
def self.to_markdown(node, options = {})
|
27
|
+
serializer = Serializers::MarkdownSerializer.new(
|
28
|
+
Serializers::MarkdownSerializer::DEFAULT_NODE_SERIALIZERS,
|
29
|
+
Serializers::MarkdownSerializer::DEFAULT_MARK_SERIALIZERS,
|
30
|
+
options
|
31
|
+
)
|
32
|
+
serializer.serialize(node)
|
33
|
+
end
|
34
|
+
end
|
metadata
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: prose_mirror_ruby
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Robert Ross
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-03-07 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '13.0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '13.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3.0'
|
41
|
+
description: A library for working with ProseMirror documents in Ruby, including conversion
|
42
|
+
between ProseMirror JSON and other formats such as Markdown.
|
43
|
+
email:
|
44
|
+
- robert@firehydrant.com
|
45
|
+
executables: []
|
46
|
+
extensions: []
|
47
|
+
extra_rdoc_files: []
|
48
|
+
files:
|
49
|
+
- CHANGELOG.md
|
50
|
+
- LICENSE.txt
|
51
|
+
- README.md
|
52
|
+
- lib/prose_mirror.rb
|
53
|
+
- lib/prose_mirror/converter.rb
|
54
|
+
- lib/prose_mirror/mark.rb
|
55
|
+
- lib/prose_mirror/node.rb
|
56
|
+
- lib/prose_mirror/serializers/markdown_serializer.rb
|
57
|
+
- lib/prose_mirror/version.rb
|
58
|
+
homepage: https://github.com/firehydrant/prose_mirror
|
59
|
+
licenses:
|
60
|
+
- MIT
|
61
|
+
metadata:
|
62
|
+
homepage_uri: https://github.com/firehydrant/prose_mirror
|
63
|
+
source_code_uri: https://github.com/firehydrant/prose_mirror
|
64
|
+
changelog_uri: https://github.com/firehydrant/prose_mirror/blob/main/CHANGELOG.md
|
65
|
+
post_install_message:
|
66
|
+
rdoc_options: []
|
67
|
+
require_paths:
|
68
|
+
- lib
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: 2.6.0
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
75
|
+
requirements:
|
76
|
+
- - ">="
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: '0'
|
79
|
+
requirements: []
|
80
|
+
rubygems_version: 3.3.26
|
81
|
+
signing_key:
|
82
|
+
specification_version: 4
|
83
|
+
summary: Ruby implementation of ProseMirror document model
|
84
|
+
test_files: []
|