prosemirror 0.4.0 → 0.6.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +49 -4
  4. data/ROADMAP.md +9 -5
  5. data/TYPES.md +55 -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 +65 -13
  16. data/lib/prosemirror/markdown_serializer.rb +518 -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 +15 -0
  33. data/lib/prosemirror/nodes/code_block.rb +15 -0
  34. data/lib/prosemirror/nodes/details.rb +27 -0
  35. data/lib/prosemirror/nodes/details_content.rb +12 -0
  36. data/lib/prosemirror/nodes/details_summary.rb +12 -0
  37. data/lib/prosemirror/nodes/hard_break.rb +5 -0
  38. data/lib/prosemirror/nodes/heading.rb +6 -0
  39. data/lib/prosemirror/nodes/horizontal_rule.rb +5 -0
  40. data/lib/prosemirror/nodes/image.rb +16 -0
  41. data/lib/prosemirror/nodes/list_item.rb +4 -0
  42. data/lib/prosemirror/nodes/mention.rb +36 -0
  43. data/lib/prosemirror/nodes/ordered_list.rb +11 -0
  44. data/lib/prosemirror/nodes/paragraph.rb +8 -0
  45. data/lib/prosemirror/nodes/table.rb +36 -0
  46. data/lib/prosemirror/nodes/task_item.rb +40 -0
  47. data/lib/prosemirror/nodes/task_list.rb +24 -0
  48. data/lib/prosemirror/nodes/text.rb +4 -0
  49. data/lib/prosemirror/nodes/twitch.rb +94 -0
  50. data/lib/prosemirror/nodes/youtube.rb +71 -0
  51. data/lib/prosemirror/schema.rb +22 -6
  52. data/lib/prosemirror/text_align.rb +31 -0
  53. data/lib/prosemirror/type_definition.rb +33 -13
  54. data/lib/prosemirror/version.rb +1 -1
  55. metadata +11 -1
@@ -6,7 +6,8 @@ module Prosemirror
6
6
  # (https://github.com/gjtorikian/commonmarker), so that the schema's parse rules apply to Markdown too.
7
7
  # Each Markdown node becomes the element commonmarker renders for it, without going through HTML.
8
8
  #
9
- # - GitHub Flavored Markdown: tables, strikethrough and autolinks. Task list items stay as text (`[ ] `).
9
+ # - GitHub Flavored Markdown: tables, strikethrough, autolinks and task lists. A bullet list whose items
10
+ # all have a checkbox is a task list. In other lists, the checkboxes stay as text (`[ ] `).
10
11
  # - Raw HTML can span several Markdown blocks, like `<del>` around a paragraph: with the herb gem, a
11
12
  # document holding raw HTML is rendered to HTML by commonmarker and imported. Without it, raw HTML
12
13
  # stays as text.
@@ -15,16 +16,21 @@ module Prosemirror
15
16
  class Builder
16
17
  MAX_DEPTH = HTML::Builder::MAX_DEPTH
17
18
  EXTENSIONS = {
18
- strikethrough: true, table: true, autolink: true, tasklist: false, shortcodes: false, header_ids: nil,
19
+ strikethrough: true, table: true, autolink: true, tasklist: true, shortcodes: false, header_ids: nil,
19
20
  front_matter_delimiter: "---"
20
21
  }.freeze
21
- RENDER_OPTIONS = {extension: EXTENSIONS, render: {unsafe: true, hardbreaks: false, github_pre_lang: false}}.freeze
22
+ # `tasklist_classes` gives task lists the classes of GitHub's HTML, which the task list type reads.
23
+ RENDER_OPTIONS = {
24
+ extension: EXTENSIONS, render: {unsafe: true, hardbreaks: false, github_pre_lang: false, tasklist_classes: true}
25
+ }.freeze
22
26
  TAGS = {
23
27
  paragraph: "p", block_quote: "blockquote", item: "li", thematic_break: "hr", table_row: "tr",
24
28
  emph: "em", strong: "strong", strikethrough: "s", linebreak: "br"
25
29
  }.freeze
26
30
  # A table's delimiter row, like `| :-- | --: |`, at the end of its line.
27
31
  DELIMITER_ROW = /\|?(?:\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\s*\z/
32
+ # A task item's marker and checkbox, at its first line.
33
+ TASK_MARKER = /\A(?:[-+*]|\d+[.)])[ \t]+(\[[ xX]\])/
28
34
  # The bytes commonmarker escapes in URLs, as comrak's `escape_href`: all but these, and `%` when it
29
35
  # doesn't start an escape. `&` and `'` are kept: comrak writes them as HTML references.
30
36
  URL_UNSAFE = %r{%(?!\h\h)|[^-_.+!*(),#@?=;:/$~A-Za-z0-9&'%]}n
@@ -50,6 +56,14 @@ module Prosemirror
50
56
  root
51
57
  end
52
58
 
59
+ # The URL as commonmarker renders it, with the characters URLs can't hold percent-encoded. Markdown export
60
+ # writes URLs this way too, so that importing its output changes nothing.
61
+ def self.escape_url(url)
62
+ host = url[IPV6_HOST].to_s
63
+ rest = url.delete_prefix(host).b.gsub(URL_UNSAFE) { |byte| byte.ord.zero? ? "%EF%BF%BD" : format("%%%02X", byte.ord) }
64
+ host + rest.force_encoding(Encoding::UTF_8)
65
+ end
66
+
53
67
  private
54
68
 
55
69
  def parse(markdown)
@@ -78,10 +92,17 @@ module Prosemirror
78
92
 
79
93
  # Adds a node to the target. Returns the element to add its children to, when they need adding.
80
94
  def add(node, target)
81
- case node.type
82
- when *TAGS.keys then HTML::Element.new(TAGS[node.type])
95
+ type = node.type # commonmarker allocates for each call
96
+ return checkbox_text(node, type, target) if @checkbox
97
+
98
+ if (tag = TAGS[type])
99
+ return HTML::Element.new(tag)
100
+ end
101
+
102
+ case type
83
103
  when :heading then HTML::Element.new("h#{node.header_level}")
84
104
  when :list then list(node)
105
+ when :taskitem then task_item(node, target)
85
106
  when :code_block then code_block(node, target)
86
107
  when :table then table(node)
87
108
  when :table_cell then table_cell(node)
@@ -98,11 +119,44 @@ module Prosemirror
98
119
  end
99
120
 
100
121
  def list(node)
101
- return HTML::Element.new("ul") unless node.list_type == :ordered
122
+ if node.list_type != :ordered
123
+ return HTML::Element.new("ul", (node.all? { |item| item.type == :taskitem }) ? {"data-type" => "taskList"} : {})
124
+ end
102
125
 
103
126
  HTML::Element.new("ol", (node.list_start == 1) ? {} : {"start" => node.list_start.to_s})
104
127
  end
105
128
 
129
+ # commonmarker doesn't say whether a task item is checked: its checkbox is read from its first line.
130
+ # Outside a task list, the checkbox is written as text, at the start of the item's first paragraph.
131
+ def task_item(node, target)
132
+ position = node.source_position
133
+ checkbox = @lines[position[:start_line] - 1].to_s.byteslice((position[:start_column] - 1)..).to_s[TASK_MARKER, 1] || "[ ]"
134
+ if target["data-type"] == "taskList"
135
+ return HTML::Element.new("li", {"data-type" => "taskItem", "data-checked" => checkbox.match?(/x/i).to_s})
136
+ end
137
+
138
+ item = HTML::Element.new("li")
139
+ if node.first_child
140
+ @checkbox = checkbox
141
+ else
142
+ text(item.append(HTML::Element.new("p")), "#{checkbox} ")
143
+ end
144
+ item
145
+ end
146
+
147
+ # The first child of a task item outside a task list: its paragraph starts with the checkbox, or a
148
+ # paragraph holding it goes before.
149
+ def checkbox_text(node, type, target)
150
+ checkbox = "#{@checkbox} "
151
+ @checkbox = nil
152
+ paragraph = HTML::Element.new("p")
153
+ text(paragraph, checkbox)
154
+ return paragraph if type == :paragraph
155
+
156
+ target.append(paragraph)
157
+ add(node, target)
158
+ end
159
+
106
160
  def code_block(node, target)
107
161
  language = node.fence_info.to_s.split.first
108
162
  code = HTML::Element.new("code", language ? {"class" => "language-#{language}"} : {})
@@ -157,7 +211,10 @@ module Prosemirror
157
211
  end
158
212
 
159
213
  def raw_html?(document)
160
- document.walk { |node| return true if node.type == :html_block || node.type == :html_inline }
214
+ document.walk do |node|
215
+ type = node.type
216
+ return true if type == :html_block || type == :html_inline
217
+ end
161
218
  false
162
219
  end
163
220
 
@@ -181,12 +238,7 @@ module Prosemirror
181
238
  end
182
239
  end
183
240
 
184
- # The URL as commonmarker renders it, with the characters URLs can't hold percent-encoded.
185
- def escape_url(url)
186
- host = url[IPV6_HOST].to_s
187
- rest = url.delete_prefix(host).b.gsub(URL_UNSAFE) { |byte| byte.ord.zero? ? "%EF%BF%BD" : format("%%%02X", byte.ord) }
188
- host + rest.force_encoding(Encoding::UTF_8)
189
- end
241
+ def escape_url(url) = self.class.escape_url(url)
190
242
 
191
243
  def plain_text(node)
192
244
  node.to_a.map { |child| %i[text code].include?(child.type) ? child.string_content : plain_text(child) }.join
@@ -0,0 +1,518 @@
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. The block, called with the siblings before, tells
217
+ # which lists share markers, when other types than the list's do.
218
+ def list_after_list?(parent, index, &same)
219
+ same ||= ->(sibling) { sibling.type == parent.content[index].type }
220
+ run = 0
221
+ run += 1 while index - run - 1 >= 0 && same.call(parent.content[index - run - 1])
222
+ run.odd?
223
+ end
224
+
225
+ # The string escaped so that it reads as text in Markdown. At the start of a line, characters that
226
+ # start a block are escaped too. `<` before a tag and `&` before a character reference are escaped, so
227
+ # that they aren't read as HTML.
228
+ def esc(string, start_of_line: false)
229
+ string = string.gsub(/[`*\\~\[\]_]/) do |char|
230
+ index = Regexp.last_match.begin(0)
231
+ # Between letters or digits, `_` can't be emphasis. Not between underscores: `__END__`.
232
+ intraword = char == "_" && index.positive? && index + 1 < string.length &&
233
+ string[index - 1].match?(/[[:alnum:]]/) && string[index + 1].match?(/[[:alnum:]]/)
234
+ intraword ? char : "\\#{char}"
235
+ end
236
+ string = string.gsub(%r{<(?=[A-Za-z/!?])}, "\\<").gsub(/&(?=#?\w+;)/) { "\\&" }
237
+ # URLs and email addresses in text would become links: GitHub Flavored Markdown links them.
238
+ string = string.gsub(%r{\b(https?|ftp)://}i) { "#{Regexp.last_match(1)}\\://" }
239
+ .gsub(/\bwww\./i) { "#{Regexp.last_match(0)[0, 3]}\\." }
240
+ .gsub(/(?<=[\w.+-])@(?=[\w-]+\.\w)/, "\\@")
241
+ if start_of_line
242
+ string = string.sub(/\A(\+ |[-*>])/) { "\\#{Regexp.last_match(1)}" }
243
+ .sub(/\A(\s*)(\#{1,6})(\s|\z)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}#{Regexp.last_match(3)}" }
244
+ .sub(/\A(\s*\d+)\.\s/) { "#{Regexp.last_match(1)}\\. " }
245
+ end
246
+ string
247
+ end
248
+
249
+ def repeat(string, count)
250
+ string * count
251
+ end
252
+
253
+ # The opening or closing syntax of a mark.
254
+ def mark_string(mark, open, parent, index)
255
+ value = mark_spec(mark)[open ? :open : :close]
256
+ value.respond_to?(:call) ? value.call(self, mark, parent, index) : value
257
+ end
258
+
259
+ # The mark's syntax: its type's `markdown`, its HTML with `html: true`, or nothing.
260
+ def mark_spec(mark)
261
+ type = @schema.mark_type(mark.type)
262
+ type&.markdown_spec || ((@html && type) ? html_mark(mark, type) : BLANK_MARK)
263
+ end
264
+
265
+ # The backticks that open (`side` -1) or close (1) inline code around this node's text: one more than
266
+ # its longest run of backticks, with a space when it has some.
267
+ def backticks_for(node, side)
268
+ length = node&.text? ? node.text.scan(/`+/).map(&:length).max.to_i : 0
269
+ ticks = "`" * (length + 1)
270
+ return ticks if length.zero?
271
+
272
+ side.negative? ? "#{ticks} " : " #{ticks}"
273
+ end
274
+
275
+ # Whether a link can be written as an autolink, `<https://…>`: its text is its URL, without a title.
276
+ def plain_url?(link, parent, index)
277
+ href = link.attrs[:href].to_s
278
+ return false if link.attrs[:title] || !href.match?(/\A\w+:/)
279
+
280
+ content = parent.content[index]
281
+ return false unless content&.text? && content.text == href && content.marks.last == link
282
+
283
+ index == parent.content.size - 1 || !parent.content[index + 1].marks.include?(link)
284
+ end
285
+
286
+ # A link destination, percent-encoded as Markdown import gives it back, with its parentheses escaped.
287
+ def url(href)
288
+ Markdown::Builder.escape_url(href.to_s).gsub(/[()]/) { "\\#{Regexp.last_match(0)}" }
289
+ end
290
+
291
+ # A link title, quoted.
292
+ def title(value)
293
+ value ? %( "#{value.gsub('"', '\\"')}") : ""
294
+ end
295
+
296
+ # A node's HTML, for formatting without Markdown syntax.
297
+ def node_html(node)
298
+ Document.new(Node.new(type: @schema.top_node, content: [node]), schema: @schema).to_phlex.call
299
+ end
300
+
301
+ private
302
+
303
+ def flush_close(size = 2)
304
+ return unless @closed
305
+
306
+ @out << "\n" unless at_blank?
307
+ if size > 1
308
+ delim = @delim.sub(/\s+\z/, "")
309
+ (size - 1).times { @out << delim << "\n" }
310
+ end
311
+ @closed = nil
312
+ end
313
+
314
+ # Removes the spaces and tabs ending the output after other text. Reads bytes from the end: the output
315
+ # can be long, and a regular expression would scan it all for each block.
316
+ def strip_trailing_spaces
317
+ size = @out.bytesize
318
+ size -= 1 while size.positive? && [32, 9].include?(@out.getbyte(size - 1))
319
+ return if size == @out.bytesize || size.zero? || [10, 13].include?(@out.getbyte(size - 1))
320
+
321
+ @out.replace(@out.byteslice(0, size))
322
+ end
323
+
324
+ def at_blank?
325
+ @out.empty? || @out.end_with?("\n")
326
+ end
327
+
328
+ def hard_break?(node)
329
+ @schema.node_type(node.type)&.linebreak_replacement?
330
+ end
331
+
332
+ # Whether the mark continues after `index`, past hard breaks.
333
+ def mark_ahead?(parent, index, mark)
334
+ loop do
335
+ following = parent.content[index]
336
+ return false unless following
337
+ return following.marks.include?(mark) unless hard_break?(following)
338
+
339
+ index += 1
340
+ end
341
+ end
342
+
343
+ # The marks with the mixable ones about to open sorted by how far they go: the longest opens first,
344
+ # outside the others, so that it doesn't close and reopen when a shorter one ends, like bold around a
345
+ # link and the text after it.
346
+ def longest_first(marks, from, length, parent, index)
347
+ opening = marks[from...length]
348
+ return marks if opening.size < 2 || !opening.all? { |mark| mark_spec(mark)[:mixable] }
349
+
350
+ sorted = opening.each_with_index.sort_by { |mark, position| [-run_length(parent, index, mark), position] }.map(&:first)
351
+ marks[0...from] + sorted + marks[length..]
352
+ end
353
+
354
+ # How many nodes from `index` on have the mark, without the spaces at the end, which move out of it.
355
+ def run_length(parent, index, mark)
356
+ length = 0
357
+ length += 1 while parent.content[index + length]&.marks&.include?(mark)
358
+ length -= 1 while length > 1 && (node = parent.content[index + length - 1]).text? && node.text.match?(SPACES_ONLY)
359
+ length
360
+ end
361
+
362
+ # The parent without the emphasis marks Markdown can't write where they are: as CommonMark's flanking
363
+ # rules require, `**` opens before a non-space, and when it's punctuation, after a space or punctuation,
364
+ # like `Torra**,**`, which can't be bold. Their text stays.
365
+ #
366
+ # When punctuation at an edge of the run is the problem, it moves out of the mark first, as in
367
+ # `l’‘*Association*` for an italic `‘Association`. Each run that can't be written is handled in turn.
368
+ def without_unflanked_marks(parent)
369
+ content = parent.content
370
+ while (run = unflanked_run(content))
371
+ content = fitted(content, *run)
372
+ end
373
+ content.equal?(parent.content) ? parent : parent.with(content: content)
374
+ end
375
+
376
+ # The first emphasis mark, with the indexes of its run, that can't be written, or nil.
377
+ def unflanked_run(content)
378
+ content.each_with_index do |node, index|
379
+ node.marks.each do |mark|
380
+ next unless mark_spec(mark)[:expel_enclosing_whitespace]
381
+ next if index.positive? && content[index - 1].marks.include?(mark) # inside a run
382
+
383
+ last = index
384
+ last += 1 while content[last + 1]&.marks&.include?(mark)
385
+ return [mark, index, last] unless flanks(content, index, last).all?
386
+ end
387
+ end
388
+ nil
389
+ end
390
+
391
+ # The content with the mark's edge punctuation moved out of its run, when that's enough, or else
392
+ # without the mark on the run.
393
+ def fitted(content, mark, first, last)
394
+ opens, closes = flanks(content, first, last)
395
+ trimmed = content.dup
396
+ run_first = first
397
+ run_last = last
398
+ if !closes && (split = split_edge(trimmed[last], mark, /[[:punct:]]+\z/, after: true))
399
+ trimmed[last, 1] = split
400
+ end
401
+ if !opens && (split = split_edge(trimmed[first], mark, /\A[[:punct:]]+/, after: false))
402
+ trimmed[first, 1] = split
403
+ run_first += 1
404
+ run_last += 1
405
+ end
406
+ return trimmed if trimmed.size > content.size && flanks(trimmed, run_first, run_last).all?
407
+
408
+ content.each_with_index.map { |node, index| (first..last).cover?(index) ? node.with(marks: node.marks - [mark]) : node }
409
+ end
410
+
411
+ # The text node split into its punctuation edge without the mark, and its rest with it, or nil.
412
+ def split_edge(node, mark, pattern, after:)
413
+ return nil unless node.text?
414
+
415
+ edge = node.text[pattern]
416
+ return nil if edge.nil? || edge.length == node.text.length
417
+
418
+ rest = after ? node.text[0...-edge.length] : node.text[edge.length..]
419
+ outside = node.with(text: edge, marks: node.marks - [mark])
420
+ inside = node.with(text: rest)
421
+ after ? [inside, outside] : [outside, inside]
422
+ end
423
+
424
+ # Whether the delimiters around the nodes from `first` to `last` can open and close emphasis, as
425
+ # `[opens, closes]`. The characters around them are the ones written: the backticks of code, the
426
+ # brackets of links.
427
+ def flanks(content, first, last)
428
+ run_marks = content[first].marks
429
+ text = content[first..last].map { |node| written(node, run_marks) }.join
430
+ lead = text[SPACES_START]
431
+ inner, trail = split_trailing_space(text[lead.length..])
432
+ return [true, true] if inner.empty?
433
+
434
+ before = lead.empty? ? neighbor_char(content, first, -1, run_marks) : " "
435
+ after = trail.empty? ? neighbor_char(content, last, 1, run_marks) : " "
436
+ [left_flanking?(before, inner[0]), right_flanking?(inner[-1], after)]
437
+ end
438
+
439
+ # The written character next to a node, before it (`step` -1) or after it (1), or nil at an edge.
440
+ def neighbor_char(content, index, step, run_marks)
441
+ loop do
442
+ index += step
443
+ return nil if index.negative? || index >= content.size
444
+
445
+ text = written(content[index], run_marks)
446
+ return step.negative? ? text[-1] : text[0] unless text.empty?
447
+ end
448
+ end
449
+
450
+ # The text without its trailing spaces, and the spaces. Linear: a regular expression for them would
451
+ # backtrack over long texts.
452
+ def split_trailing_space(text)
453
+ last = text.rindex(/[^[:space:]]/)
454
+ last ? [text[0..last], text[(last + 1)..]] : ["", text]
455
+ end
456
+
457
+ # A node's text with the syntax of its code and link marks, other than the ones of the run.
458
+ def written(node, run_marks)
459
+ text = node.text? ? node.text : "" # an object, like an image: neither space nor punctuation
460
+ node.marks.each do |mark|
461
+ next if run_marks.include?(mark)
462
+
463
+ text = "`#{text}`" if mark_spec(mark)[:escape] == false
464
+ text = "[#{text})" if mark_spec(mark).equal?(LINK)
465
+ end
466
+ text
467
+ end
468
+
469
+ def left_flanking?(before, following)
470
+ !space?(following) && (!punctuation?(following) || space?(before) || punctuation?(before))
471
+ end
472
+
473
+ def right_flanking?(preceding, after)
474
+ !space?(preceding) && (!punctuation?(preceding) || space?(after) || punctuation?(after))
475
+ end
476
+
477
+ def space?(char)
478
+ char.nil? || char.match?(/[[:space:]]/)
479
+ end
480
+
481
+ def punctuation?(char)
482
+ !char.nil? && char.match?(/[\p{P}\p{S}]/)
483
+ end
484
+
485
+ # Marks like bold and italic can open and close in any order: they are put in the order of the open
486
+ # marks, so that fewer of them close and reopen.
487
+ def reorder_mixable(marks, active, length)
488
+ marks = marks.dup
489
+ (0...length).each do |i|
490
+ mark = marks[i]
491
+ break unless mark_spec(mark)[:mixable]
492
+
493
+ active.each_with_index do |other, j|
494
+ break unless mark_spec(other)[:mixable]
495
+ next unless mark == other
496
+
497
+ if i > j
498
+ marks = marks[0...j] + [mark] + marks[j...i] + marks[(i + 1)...length] + marks[length..]
499
+ elsif j > i
500
+ marks = marks[0...i] + marks[(i + 1)...j] + [mark] + marks[j...length] + marks[length..]
501
+ end
502
+ break
503
+ end
504
+ end
505
+ marks
506
+ end
507
+
508
+ # The HTML a mark type renders, split around its content.
509
+ def html_mark(mark, type)
510
+ @html_marks[mark] ||= begin
511
+ component = type.new(**mark.attrs)
512
+ component.instance_variable_set(:@_prosemirror_schema, @schema)
513
+ open, close = component.call { CONTENT }.split(CONTENT, 2)
514
+ {open: open.to_s, close: close.to_s, mixable: true}.freeze
515
+ end
516
+ end
517
+ end
518
+ 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