prosemirror 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +18 -0
  4. data/ROADMAP.md +5 -3
  5. data/TYPES.md +43 -0
  6. data/lib/prosemirror/checker.rb +2 -2
  7. data/lib/prosemirror/coerce.rb +7 -0
  8. data/lib/prosemirror/document.rb +6 -0
  9. data/lib/prosemirror/html/element.rb +1 -1
  10. data/lib/prosemirror/html/selector.rb +12 -4
  11. data/lib/prosemirror/html_parser.rb +9 -2
  12. data/lib/prosemirror/json_parser.rb +71 -40
  13. data/lib/prosemirror/mark.rb +8 -3
  14. data/lib/prosemirror/mark_type.rb +15 -0
  15. data/lib/prosemirror/markdown/builder.rb +19 -9
  16. data/lib/prosemirror/markdown_serializer.rb +516 -0
  17. data/lib/prosemirror/marks/basic/code.rb +2 -0
  18. data/lib/prosemirror/marks/basic/em.rb +2 -0
  19. data/lib/prosemirror/marks/basic/link.rb +2 -0
  20. data/lib/prosemirror/marks/basic/strong.rb +2 -0
  21. data/lib/prosemirror/marks/bold.rb +2 -0
  22. data/lib/prosemirror/marks/code.rb +2 -0
  23. data/lib/prosemirror/marks/italic.rb +2 -0
  24. data/lib/prosemirror/marks/link.rb +2 -0
  25. data/lib/prosemirror/marks/strike.rb +2 -0
  26. data/lib/prosemirror/node.rb +21 -6
  27. data/lib/prosemirror/node_type.rb +13 -0
  28. data/lib/prosemirror/nodes/basic/code_block.rb +4 -0
  29. data/lib/prosemirror/nodes/basic/image.rb +4 -0
  30. data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -0
  31. data/lib/prosemirror/nodes/blockquote.rb +4 -0
  32. data/lib/prosemirror/nodes/bullet_list.rb +6 -0
  33. data/lib/prosemirror/nodes/code_block.rb +15 -0
  34. data/lib/prosemirror/nodes/hard_break.rb +5 -0
  35. data/lib/prosemirror/nodes/heading.rb +6 -0
  36. data/lib/prosemirror/nodes/horizontal_rule.rb +5 -0
  37. data/lib/prosemirror/nodes/image.rb +16 -0
  38. data/lib/prosemirror/nodes/list_item.rb +4 -0
  39. data/lib/prosemirror/nodes/ordered_list.rb +11 -0
  40. data/lib/prosemirror/nodes/paragraph.rb +8 -0
  41. data/lib/prosemirror/nodes/table.rb +36 -0
  42. data/lib/prosemirror/nodes/text.rb +4 -0
  43. data/lib/prosemirror/schema.rb +14 -2
  44. data/lib/prosemirror/type_definition.rb +33 -13
  45. data/lib/prosemirror/version.rb +1 -1
  46. metadata +2 -1
@@ -0,0 +1,516 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Writes a document as Markdown, as prosemirror-markdown's MarkdownSerializerState, from which this is
5
+ # ported: https://github.com/ProseMirror/prosemirror-markdown
6
+ #
7
+ # Node types write themselves in `to_markdown`, with the methods of this class, and mark types declare
8
+ # their syntax with `markdown`. Formatting without Markdown syntax, like underline, keeps only its text,
9
+ # or becomes HTML with `html: true`.
10
+ class MarkdownSerializer
11
+ # No syntax: the mark's text only.
12
+ BLANK_MARK = {open: "", close: "", mixable: true}.freeze
13
+ # Unicode spaces, as CommonMark's flanking rules count them, non-breaking spaces included.
14
+ SPACES_START = /\A[[:space:]]*/
15
+ SPACES_ONLY = /\A[[:space:]]*\z/
16
+ # Stands for the content when rendering a mark's HTML, to split it into its opening and closing tags.
17
+ CONTENT = "\u0000"
18
+ # `[text](href "title")`, or `<href>` when the text is the URL.
19
+ LINK = {
20
+ open: lambda { |markdown, mark, parent, index|
21
+ markdown.in_autolink = markdown.plain_url?(mark, parent, index)
22
+ markdown.in_autolink ? "<" : "["
23
+ },
24
+ close: lambda { |markdown, mark, _parent, _index|
25
+ autolink = markdown.in_autolink
26
+ markdown.in_autolink = nil
27
+ autolink ? ">" : "](#{markdown.url(mark.attrs[:href])}#{markdown.title(mark.attrs[:title])})"
28
+ },
29
+ mixable: true
30
+ }.freeze
31
+ # Inline code, with more backticks than the code holds.
32
+ CODE = {
33
+ open: ->(markdown, _mark, parent, index) { markdown.backticks_for(parent.content[index], -1) },
34
+ close: ->(markdown, _mark, parent, index) { markdown.backticks_for(parent.content[index - 1], 1) },
35
+ escape: false
36
+ }.freeze
37
+
38
+ attr_reader :schema, :out
39
+ attr_accessor :in_autolink, :in_tight_list
40
+
41
+ def initialize(schema, html: false)
42
+ @schema = schema
43
+ @html = html
44
+ @delim = +""
45
+ @out = +""
46
+ @closed = nil
47
+ @in_autolink = nil
48
+ @at_block_start = false
49
+ @in_tight_list = false
50
+ @html_marks = {}
51
+ end
52
+
53
+ # The document's content as Markdown, ending with a newline.
54
+ def call(root)
55
+ render_content(root)
56
+ @out.empty? ? @out : @out.sub(/\n*\z/, "\n")
57
+ end
58
+
59
+ # Whether formatting without Markdown syntax is written as HTML.
60
+ def html?
61
+ @html
62
+ end
63
+
64
+ # Render a block, prefixing each line with `delim`, and the first one with `first_delim`. `node` is closed
65
+ # at the end of the block.
66
+ def wrap_block(delim, first_delim, node)
67
+ old = @delim
68
+ write(first_delim || delim)
69
+ @delim += delim
70
+ yield
71
+ @delim = old
72
+ close_block(node)
73
+ end
74
+
75
+ def ensure_new_line
76
+ @out << "\n" unless at_blank?
77
+ end
78
+
79
+ # Writes the content as it is, after closing the previous block and adding the current delimiters.
80
+ def write(content = nil)
81
+ flush_close
82
+ @out << @delim if !@delim.empty? && at_blank?
83
+ @out << content if content
84
+ end
85
+
86
+ def close_block(node)
87
+ @closed = node
88
+ end
89
+
90
+ # Writes text, escaped unless `escape` is false.
91
+ def text(text, escape: true)
92
+ lines = text.split("\n", -1)
93
+ lines.each_with_index do |line, index|
94
+ write
95
+ # Markdown drops spaces at the start of a block, and after a hard break.
96
+ line = line.lstrip if escape && index.zero? && (@at_block_start || @out.end_with?("\\\n#{@delim}"))
97
+ # An exclamation mark before a link would make it an image.
98
+ @out << "\\!" if !escape && line.start_with?("[") && @out.end_with?("!") && !@out.end_with?("\\!") && @out.chop!
99
+ @out << (escape ? esc(line, start_of_line: @at_block_start) : line)
100
+ @out << "\n" unless index == lines.size - 1
101
+ end
102
+ end
103
+
104
+ # Renders a node with its type's `to_markdown`.
105
+ def render(node, parent, index)
106
+ type = @schema.node_type(node.type)
107
+ if type
108
+ component = type.new(**node.attrs)
109
+ component.instance_variable_set(:@_prosemirror_node, node)
110
+ component.instance_variable_set(:@_prosemirror_schema, @schema)
111
+ component.to_markdown(self, parent, index)
112
+ elsif !node.content.empty?
113
+ render_content(node)
114
+ close_block(node)
115
+ end
116
+ end
117
+
118
+ # Renders the content of `parent` as blocks.
119
+ def render_content(parent)
120
+ parent.content.each_with_index { |node, index| render(node, parent, index) }
121
+ end
122
+
123
+ # Renders the content of `parent` as inline content, with its marks.
124
+ def render_inline(parent, from_block_start: true)
125
+ parent = without_unflanked_marks(parent)
126
+ @at_block_start = from_block_start
127
+ active = []
128
+ trailing = +""
129
+ progress = lambda do |node, index|
130
+ marks = node ? node.marks : []
131
+
132
+ # Marks on a final hard break would put a newline just before their closing syntax.
133
+ if node && hard_break?(node)
134
+ marks = marks.select do |mark|
135
+ following = parent.content[index + 1]
136
+ following&.marks&.include?(mark) && (!following.text? || following.text.match?(/\S/))
137
+ end
138
+ end
139
+
140
+ leading = trailing
141
+ trailing = +""
142
+ # Whitespace inside emphasis isn't allowed: it moves out of the marks.
143
+ if node&.text? && marks.any? { |mark| mark_spec(mark)[:expel_enclosing_whitespace] && !active.include?(mark) }
144
+ lead = node.text[SPACES_START]
145
+ rest = node.text[lead.length..]
146
+ unless lead.empty?
147
+ leading += lead
148
+ node = rest.empty? ? nil : node.with(text: rest)
149
+ marks = active unless node
150
+ end
151
+ end
152
+ if node&.text? && marks.any? { |mark| mark_spec(mark)[:expel_enclosing_whitespace] && !mark_ahead?(parent, index + 1, mark) }
153
+ rest, trail = split_trailing_space(node.text)
154
+ unless trail.empty?
155
+ trailing = trail
156
+ node = rest.empty? ? nil : node.with(text: rest)
157
+ marks = active unless node
158
+ end
159
+ end
160
+
161
+ inner = marks.last
162
+ no_escape = inner && mark_spec(inner)[:escape] == false
163
+ length = marks.size - (no_escape ? 1 : 0)
164
+ marks = reorder_mixable(marks, active, length)
165
+
166
+ # Close the marks that changed, write the expelled whitespace, then open the new marks.
167
+ keep = 0
168
+ keep += 1 while keep < [active.size, length].min && marks[keep] == active[keep]
169
+ text(mark_string(active.pop, false, parent, index), escape: false) while keep < active.size
170
+ text(leading) unless leading.empty?
171
+
172
+ if node
173
+ marks = longest_first(marks, active.size, length, parent, index)
174
+ while active.size < length
175
+ mark = marks[active.size]
176
+ active << mark
177
+ text(mark_string(mark, true, parent, index), escape: false)
178
+ @at_block_start = false
179
+ end
180
+
181
+ if no_escape && node.text?
182
+ text(mark_string(inner, true, parent, index) + node.text + mark_string(inner, false, parent, index + 1), escape: false)
183
+ else
184
+ render(node, parent, index)
185
+ end
186
+ @at_block_start = false
187
+ end
188
+ @at_block_start = false if node&.text? && !node.text.empty?
189
+ end
190
+ parent.content.each_with_index { |node, index| progress.call(node, index) }
191
+ progress.call(nil, parent.content.size)
192
+ # Markdown drops spaces at the end of a block too.
193
+ strip_trailing_spaces
194
+ @at_block_start = false
195
+ end
196
+
197
+ # Renders a list. `delim` indents the lines of an item after its first, and the block gives the marker
198
+ # of an item's first line from its index. A list is tight when no item holds more than one block, other
199
+ # than nested lists.
200
+ def render_list(node, delim)
201
+ flush_close(1) if @in_tight_list && !(@closed && @closed.type == node.type)
202
+
203
+ tight = node.content.all? do |item|
204
+ item.content.count { |child| !@schema.node_type(child.type)&.group&.include?(:list) } <= 1
205
+ end
206
+ previous_tight = @in_tight_list
207
+ @in_tight_list = tight
208
+ node.content.each_with_index do |child, index|
209
+ flush_close(1) if index.positive? && tight
210
+ wrap_block(delim, yield(index), node) { render(child, node, index) }
211
+ end
212
+ @in_tight_list = previous_tight
213
+ end
214
+
215
+ # Whether the list follows a list of the same type, which it would continue in Markdown with the same
216
+ # marker. Alternating lists in a row switch markers.
217
+ def list_after_list?(parent, index)
218
+ run = 0
219
+ run += 1 while index - run - 1 >= 0 && parent.content[index - run - 1].type == parent.content[index].type
220
+ run.odd?
221
+ end
222
+
223
+ # The string escaped so that it reads as text in Markdown. At the start of a line, characters that
224
+ # start a block are escaped too. `<` before a tag and `&` before a character reference are escaped, so
225
+ # that they aren't read as HTML.
226
+ def esc(string, start_of_line: false)
227
+ string = string.gsub(/[`*\\~\[\]_]/) do |char|
228
+ index = Regexp.last_match.begin(0)
229
+ # Between letters or digits, `_` can't be emphasis. Not between underscores: `__END__`.
230
+ intraword = char == "_" && index.positive? && index + 1 < string.length &&
231
+ string[index - 1].match?(/[[:alnum:]]/) && string[index + 1].match?(/[[:alnum:]]/)
232
+ intraword ? char : "\\#{char}"
233
+ end
234
+ string = string.gsub(%r{<(?=[A-Za-z/!?])}, "\\<").gsub(/&(?=#?\w+;)/) { "\\&" }
235
+ # URLs and email addresses in text would become links: GitHub Flavored Markdown links them.
236
+ string = string.gsub(%r{\b(https?|ftp)://}i) { "#{Regexp.last_match(1)}\\://" }
237
+ .gsub(/\bwww\./i) { "#{Regexp.last_match(0)[0, 3]}\\." }
238
+ .gsub(/(?<=[\w.+-])@(?=[\w-]+\.\w)/, "\\@")
239
+ if start_of_line
240
+ string = string.sub(/\A(\+ |[-*>])/) { "\\#{Regexp.last_match(1)}" }
241
+ .sub(/\A(\s*)(\#{1,6})(\s|\z)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}#{Regexp.last_match(3)}" }
242
+ .sub(/\A(\s*\d+)\.\s/) { "#{Regexp.last_match(1)}\\. " }
243
+ end
244
+ string
245
+ end
246
+
247
+ def repeat(string, count)
248
+ string * count
249
+ end
250
+
251
+ # The opening or closing syntax of a mark.
252
+ def mark_string(mark, open, parent, index)
253
+ value = mark_spec(mark)[open ? :open : :close]
254
+ value.respond_to?(:call) ? value.call(self, mark, parent, index) : value
255
+ end
256
+
257
+ # The mark's syntax: its type's `markdown`, its HTML with `html: true`, or nothing.
258
+ def mark_spec(mark)
259
+ type = @schema.mark_type(mark.type)
260
+ type&.markdown_spec || ((@html && type) ? html_mark(mark, type) : BLANK_MARK)
261
+ end
262
+
263
+ # The backticks that open (`side` -1) or close (1) inline code around this node's text: one more than
264
+ # its longest run of backticks, with a space when it has some.
265
+ def backticks_for(node, side)
266
+ length = node&.text? ? node.text.scan(/`+/).map(&:length).max.to_i : 0
267
+ ticks = "`" * (length + 1)
268
+ return ticks if length.zero?
269
+
270
+ side.negative? ? "#{ticks} " : " #{ticks}"
271
+ end
272
+
273
+ # Whether a link can be written as an autolink, `<https://…>`: its text is its URL, without a title.
274
+ def plain_url?(link, parent, index)
275
+ href = link.attrs[:href].to_s
276
+ return false if link.attrs[:title] || !href.match?(/\A\w+:/)
277
+
278
+ content = parent.content[index]
279
+ return false unless content&.text? && content.text == href && content.marks.last == link
280
+
281
+ index == parent.content.size - 1 || !parent.content[index + 1].marks.include?(link)
282
+ end
283
+
284
+ # A link destination, percent-encoded as Markdown import gives it back, with its parentheses escaped.
285
+ def url(href)
286
+ Markdown::Builder.escape_url(href.to_s).gsub(/[()]/) { "\\#{Regexp.last_match(0)}" }
287
+ end
288
+
289
+ # A link title, quoted.
290
+ def title(value)
291
+ value ? %( "#{value.gsub('"', '\\"')}") : ""
292
+ end
293
+
294
+ # A node's HTML, for formatting without Markdown syntax.
295
+ def node_html(node)
296
+ Document.new(Node.new(type: @schema.top_node, content: [node]), schema: @schema).to_phlex.call
297
+ end
298
+
299
+ private
300
+
301
+ def flush_close(size = 2)
302
+ return unless @closed
303
+
304
+ @out << "\n" unless at_blank?
305
+ if size > 1
306
+ delim = @delim.sub(/\s+\z/, "")
307
+ (size - 1).times { @out << delim << "\n" }
308
+ end
309
+ @closed = nil
310
+ end
311
+
312
+ # Removes the spaces and tabs ending the output after other text. Reads bytes from the end: the output
313
+ # can be long, and a regular expression would scan it all for each block.
314
+ def strip_trailing_spaces
315
+ size = @out.bytesize
316
+ size -= 1 while size.positive? && [32, 9].include?(@out.getbyte(size - 1))
317
+ return if size == @out.bytesize || size.zero? || [10, 13].include?(@out.getbyte(size - 1))
318
+
319
+ @out.replace(@out.byteslice(0, size))
320
+ end
321
+
322
+ def at_blank?
323
+ @out.empty? || @out.end_with?("\n")
324
+ end
325
+
326
+ def hard_break?(node)
327
+ @schema.node_type(node.type)&.linebreak_replacement?
328
+ end
329
+
330
+ # Whether the mark continues after `index`, past hard breaks.
331
+ def mark_ahead?(parent, index, mark)
332
+ loop do
333
+ following = parent.content[index]
334
+ return false unless following
335
+ return following.marks.include?(mark) unless hard_break?(following)
336
+
337
+ index += 1
338
+ end
339
+ end
340
+
341
+ # The marks with the mixable ones about to open sorted by how far they go: the longest opens first,
342
+ # outside the others, so that it doesn't close and reopen when a shorter one ends, like bold around a
343
+ # link and the text after it.
344
+ def longest_first(marks, from, length, parent, index)
345
+ opening = marks[from...length]
346
+ return marks if opening.size < 2 || !opening.all? { |mark| mark_spec(mark)[:mixable] }
347
+
348
+ sorted = opening.each_with_index.sort_by { |mark, position| [-run_length(parent, index, mark), position] }.map(&:first)
349
+ marks[0...from] + sorted + marks[length..]
350
+ end
351
+
352
+ # How many nodes from `index` on have the mark, without the spaces at the end, which move out of it.
353
+ def run_length(parent, index, mark)
354
+ length = 0
355
+ length += 1 while parent.content[index + length]&.marks&.include?(mark)
356
+ length -= 1 while length > 1 && (node = parent.content[index + length - 1]).text? && node.text.match?(SPACES_ONLY)
357
+ length
358
+ end
359
+
360
+ # The parent without the emphasis marks Markdown can't write where they are: as CommonMark's flanking
361
+ # rules require, `**` opens before a non-space, and when it's punctuation, after a space or punctuation,
362
+ # like `Torra**,**`, which can't be bold. Their text stays.
363
+ #
364
+ # When punctuation at an edge of the run is the problem, it moves out of the mark first, as in
365
+ # `l’‘*Association*` for an italic `‘Association`. Each run that can't be written is handled in turn.
366
+ def without_unflanked_marks(parent)
367
+ content = parent.content
368
+ while (run = unflanked_run(content))
369
+ content = fitted(content, *run)
370
+ end
371
+ content.equal?(parent.content) ? parent : parent.with(content: content)
372
+ end
373
+
374
+ # The first emphasis mark, with the indexes of its run, that can't be written, or nil.
375
+ def unflanked_run(content)
376
+ content.each_with_index do |node, index|
377
+ node.marks.each do |mark|
378
+ next unless mark_spec(mark)[:expel_enclosing_whitespace]
379
+ next if index.positive? && content[index - 1].marks.include?(mark) # inside a run
380
+
381
+ last = index
382
+ last += 1 while content[last + 1]&.marks&.include?(mark)
383
+ return [mark, index, last] unless flanks(content, index, last).all?
384
+ end
385
+ end
386
+ nil
387
+ end
388
+
389
+ # The content with the mark's edge punctuation moved out of its run, when that's enough, or else
390
+ # without the mark on the run.
391
+ def fitted(content, mark, first, last)
392
+ opens, closes = flanks(content, first, last)
393
+ trimmed = content.dup
394
+ run_first = first
395
+ run_last = last
396
+ if !closes && (split = split_edge(trimmed[last], mark, /[[:punct:]]+\z/, after: true))
397
+ trimmed[last, 1] = split
398
+ end
399
+ if !opens && (split = split_edge(trimmed[first], mark, /\A[[:punct:]]+/, after: false))
400
+ trimmed[first, 1] = split
401
+ run_first += 1
402
+ run_last += 1
403
+ end
404
+ return trimmed if trimmed.size > content.size && flanks(trimmed, run_first, run_last).all?
405
+
406
+ content.each_with_index.map { |node, index| (first..last).cover?(index) ? node.with(marks: node.marks - [mark]) : node }
407
+ end
408
+
409
+ # The text node split into its punctuation edge without the mark, and its rest with it, or nil.
410
+ def split_edge(node, mark, pattern, after:)
411
+ return nil unless node.text?
412
+
413
+ edge = node.text[pattern]
414
+ return nil if edge.nil? || edge.length == node.text.length
415
+
416
+ rest = after ? node.text[0...-edge.length] : node.text[edge.length..]
417
+ outside = node.with(text: edge, marks: node.marks - [mark])
418
+ inside = node.with(text: rest)
419
+ after ? [inside, outside] : [outside, inside]
420
+ end
421
+
422
+ # Whether the delimiters around the nodes from `first` to `last` can open and close emphasis, as
423
+ # `[opens, closes]`. The characters around them are the ones written: the backticks of code, the
424
+ # brackets of links.
425
+ def flanks(content, first, last)
426
+ run_marks = content[first].marks
427
+ text = content[first..last].map { |node| written(node, run_marks) }.join
428
+ lead = text[SPACES_START]
429
+ inner, trail = split_trailing_space(text[lead.length..])
430
+ return [true, true] if inner.empty?
431
+
432
+ before = lead.empty? ? neighbor_char(content, first, -1, run_marks) : " "
433
+ after = trail.empty? ? neighbor_char(content, last, 1, run_marks) : " "
434
+ [left_flanking?(before, inner[0]), right_flanking?(inner[-1], after)]
435
+ end
436
+
437
+ # The written character next to a node, before it (`step` -1) or after it (1), or nil at an edge.
438
+ def neighbor_char(content, index, step, run_marks)
439
+ loop do
440
+ index += step
441
+ return nil if index.negative? || index >= content.size
442
+
443
+ text = written(content[index], run_marks)
444
+ return step.negative? ? text[-1] : text[0] unless text.empty?
445
+ end
446
+ end
447
+
448
+ # The text without its trailing spaces, and the spaces. Linear: a regular expression for them would
449
+ # backtrack over long texts.
450
+ def split_trailing_space(text)
451
+ last = text.rindex(/[^[:space:]]/)
452
+ last ? [text[0..last], text[(last + 1)..]] : ["", text]
453
+ end
454
+
455
+ # A node's text with the syntax of its code and link marks, other than the ones of the run.
456
+ def written(node, run_marks)
457
+ text = node.text? ? node.text : "" # an object, like an image: neither space nor punctuation
458
+ node.marks.each do |mark|
459
+ next if run_marks.include?(mark)
460
+
461
+ text = "`#{text}`" if mark_spec(mark)[:escape] == false
462
+ text = "[#{text})" if mark_spec(mark).equal?(LINK)
463
+ end
464
+ text
465
+ end
466
+
467
+ def left_flanking?(before, following)
468
+ !space?(following) && (!punctuation?(following) || space?(before) || punctuation?(before))
469
+ end
470
+
471
+ def right_flanking?(preceding, after)
472
+ !space?(preceding) && (!punctuation?(preceding) || space?(after) || punctuation?(after))
473
+ end
474
+
475
+ def space?(char)
476
+ char.nil? || char.match?(/[[:space:]]/)
477
+ end
478
+
479
+ def punctuation?(char)
480
+ !char.nil? && char.match?(/[\p{P}\p{S}]/)
481
+ end
482
+
483
+ # Marks like bold and italic can open and close in any order: they are put in the order of the open
484
+ # marks, so that fewer of them close and reopen.
485
+ def reorder_mixable(marks, active, length)
486
+ marks = marks.dup
487
+ (0...length).each do |i|
488
+ mark = marks[i]
489
+ break unless mark_spec(mark)[:mixable]
490
+
491
+ active.each_with_index do |other, j|
492
+ break unless mark_spec(other)[:mixable]
493
+ next unless mark == other
494
+
495
+ if i > j
496
+ marks = marks[0...j] + [mark] + marks[j...i] + marks[(i + 1)...length] + marks[length..]
497
+ elsif j > i
498
+ marks = marks[0...i] + marks[(i + 1)...j] + [mark] + marks[j...length] + marks[length..]
499
+ end
500
+ break
501
+ end
502
+ end
503
+ marks
504
+ end
505
+
506
+ # The HTML a mark type renders, split around its content.
507
+ def html_mark(mark, type)
508
+ @html_marks[mark] ||= begin
509
+ component = type.new(**mark.attrs)
510
+ component.instance_variable_set(:@_prosemirror_schema, @schema)
511
+ open, close = component.call { CONTENT }.split(CONTENT, 2)
512
+ {open: open.to_s, close: close.to_s, mixable: true}.freeze
513
+ end
514
+ end
515
+ end
516
+ end
@@ -9,6 +9,8 @@ module Prosemirror
9
9
 
10
10
  parse_html tag: "code"
11
11
 
12
+ markdown(**MarkdownSerializer::CODE)
13
+
12
14
  def view_template(&) = code(&)
13
15
  end
14
16
  end
@@ -10,6 +10,8 @@ module Prosemirror
10
10
  parse_html style: "font-style=italic"
11
11
  parse_html style: "font-style=normal", clear_mark: true
12
12
 
13
+ markdown open: "*", close: "*", mixable: true, expel_enclosing_whitespace: true
14
+
13
15
  def view_template(&) = em(&)
14
16
  end
15
17
  end
@@ -9,6 +9,8 @@ module Prosemirror
9
9
  prop :title, _String?
10
10
  parse_html(tag: "a[href]") { |element, schema| schema.allowed_url?(element["href"]) && {} }
11
11
 
12
+ markdown(**MarkdownSerializer::LINK)
13
+
12
14
  def view_template(&)
13
15
  href = safe_url(@href)
14
16
  return yield unless href
@@ -10,6 +10,8 @@ module Prosemirror
10
10
  parse_html style: "font-weight=400", clear_mark: true
11
11
  parse_html(style: "font-weight") { |value| value.match?(Bold::BOLD) && {} }
12
12
 
13
+ markdown open: "**", close: "**", mixable: true, expel_enclosing_whitespace: true
14
+
13
15
  def view_template(&) = strong(&)
14
16
  end
15
17
  end
@@ -13,6 +13,8 @@ module Prosemirror
13
13
  parse_html style: "font-weight=400", clear_mark: true
14
14
  parse_html(style: "font-weight") { |value| value.match?(BOLD) && {} }
15
15
 
16
+ markdown open: "**", close: "**", mixable: true, expel_enclosing_whitespace: true
17
+
16
18
  def view_template(&) = strong(&)
17
19
  end
18
20
  end
@@ -10,6 +10,8 @@ module Prosemirror
10
10
  parse_html tag: "code"
11
11
  parse_html tag: "tt"
12
12
 
13
+ markdown(**MarkdownSerializer::CODE)
14
+
13
15
  def view_template(&) = code(&)
14
16
  end
15
17
  end
@@ -9,6 +9,8 @@ module Prosemirror
9
9
  parse_html style: "font-style=normal", clear_mark: true
10
10
  parse_html style: "font-style=italic"
11
11
 
12
+ markdown open: "*", close: "*", mixable: true, expel_enclosing_whitespace: true
13
+
12
14
  def view_template(&) = em(&)
13
15
  end
14
16
  end
@@ -11,6 +11,8 @@ module Prosemirror
11
11
  prop :title, _String?
12
12
  parse_html(tag: "a[href]") { |element, schema| schema.allowed_url?(element["href"]) && {} }
13
13
 
14
+ markdown(**MarkdownSerializer::LINK)
15
+
14
16
  def view_template(&)
15
17
  href = safe_url(@href)
16
18
  return yield unless href
@@ -8,6 +8,8 @@ module Prosemirror
8
8
  parse_html tag: "strike"
9
9
  parse_html(style: "text-decoration", consuming: false) { |value| value.include?("line-through") ? {} : false }
10
10
 
11
+ markdown open: "~~", close: "~~", mixable: true, expel_enclosing_whitespace: true
12
+
11
13
  def view_template(&) = s(&)
12
14
  end
13
15
  end
@@ -14,15 +14,30 @@ module Prosemirror
14
14
  # Whitespace, including non-breaking spaces.
15
15
  BLANK = /\A[[:space:]]*\z/
16
16
 
17
- prop :type, Symbol, &Coerce::SYMBOL
18
- prop :attrs, _Hash(Symbol, _Any?), default: {}.freeze, &Coerce::FROZEN
19
- prop :unknown_attrs, _Hash(String, _Any?), default: {}.freeze, &Coerce::FROZEN
20
- prop :content, _Array(Node), default: [].freeze, &Coerce::FROZEN
21
- prop :marks, _Array(Mark), default: [].freeze, &Coerce::FROZEN
22
- prop :text, _String?, &Coerce::FROZEN
17
+ EMPTY_HASH = {}.freeze
18
+ EMPTY_ARRAY = [].freeze
19
+
20
+ prop :type, Symbol
21
+ prop :attrs, _Hash(Symbol, _Any?), default: EMPTY_HASH
22
+ prop :unknown_attrs, _Hash(String, _Any?), default: EMPTY_HASH
23
+ prop :content, _Array(Node), default: EMPTY_ARRAY
24
+ prop :marks, _Array(Mark), default: EMPTY_ARRAY
25
+ prop :text, _String?
26
+
27
+ # The type as a Symbol, and frozen values. Done here rather than with Literal coercion blocks, which
28
+ # cost an `instance_exec` each, for each of the many nodes a document has.
29
+ def initialize(type:, attrs: EMPTY_HASH, unknown_attrs: EMPTY_HASH, content: EMPTY_ARRAY, marks: EMPTY_ARRAY, text: nil)
30
+ super(type: type.is_a?(String) ? type.to_sym : type, attrs: Coerce.frozen(attrs), unknown_attrs: Coerce.frozen(unknown_attrs),
31
+ content: Coerce.frozen(content), marks: Coerce.frozen(marks), text: Coerce.frozen(text))
32
+ end
23
33
 
24
34
  # The nodes with adjacent text nodes that have the same marks joined, as in ProseMirror.
25
35
  def self.join_text(nodes)
36
+ return nodes unless (1...nodes.size).any? do |index|
37
+ previous, node = nodes[index - 1], nodes[index]
38
+ previous.text? && node.text? && previous.marks == node.marks
39
+ end
40
+
26
41
  nodes.each_with_object([]) do |node, joined|
27
42
  previous = joined.last
28
43
  if previous&.text? && node.text? && previous.marks == node.marks
@@ -119,5 +119,18 @@ module Prosemirror
119
119
  def view_template
120
120
  yield if block_given?
121
121
  end
122
+
123
+ # Write the node as Markdown, with the MarkdownSerializer's methods. `parent` and `index` locate the node.
124
+ # By default, the content is written, as a block for block nodes.
125
+ def to_markdown(markdown, _parent, _index)
126
+ return if self.class.leaf?
127
+
128
+ if markdown.schema.textblock?(self.class)
129
+ markdown.render_inline(node)
130
+ else
131
+ markdown.render_content(node)
132
+ end
133
+ markdown.close_block(node) if self.class.block?
134
+ end
122
135
  end
123
136
  end
@@ -14,6 +14,10 @@ module Prosemirror
14
14
  def view_template(&)
15
15
  pre { code(&) }
16
16
  end
17
+
18
+ def to_markdown(markdown, _parent, _index)
19
+ Nodes::CodeBlock.fenced(markdown, node, nil)
20
+ end
17
21
  end
18
22
  end
19
23
  end
@@ -17,6 +17,10 @@ module Prosemirror
17
17
  src = safe_url(@src)
18
18
  img(src: src, alt: @alt, title: @title) if src
19
19
  end
20
+
21
+ def to_markdown(markdown, _parent, _index)
22
+ markdown.write(Nodes::Image.markdown_image(markdown, @src, @alt, @title))
23
+ end
20
24
  end
21
25
  end
22
26
  end