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 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
@@ -0,0 +1,3 @@
1
+ module ProseMirror
2
+ VERSION = "0.1.0"
3
+ end
@@ -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: []