prosemirror 0.1.0 → 0.3.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +69 -46
  4. data/ROADMAP.md +14 -10
  5. data/TYPES.md +218 -0
  6. data/lib/prosemirror/content_match.rb +241 -0
  7. data/lib/prosemirror/html/builder.rb +154 -0
  8. data/lib/prosemirror/html/element.rb +84 -0
  9. data/lib/prosemirror/html/entities.rb +87 -0
  10. data/lib/prosemirror/html/entities.txt +2231 -0
  11. data/lib/prosemirror/html/selector.rb +95 -0
  12. data/lib/prosemirror/html/splitter.rb +68 -0
  13. data/lib/prosemirror/html/text.rb +23 -0
  14. data/lib/prosemirror/html_parser.rb +503 -0
  15. data/lib/prosemirror/json_parser.rb +1 -12
  16. data/lib/prosemirror/mark_type.rb +11 -0
  17. data/lib/prosemirror/markdown/builder.rb +200 -0
  18. data/lib/prosemirror/marks/basic/code.rb +16 -0
  19. data/lib/prosemirror/marks/basic/em.rb +4 -0
  20. data/lib/prosemirror/marks/basic/link.rb +1 -0
  21. data/lib/prosemirror/marks/basic/strong.rb +4 -0
  22. data/lib/prosemirror/marks/bold.rb +8 -0
  23. data/lib/prosemirror/marks/code.rb +5 -0
  24. data/lib/prosemirror/marks/highlight.rb +3 -1
  25. data/lib/prosemirror/marks/italic.rb +4 -0
  26. data/lib/prosemirror/marks/link.rb +1 -0
  27. data/lib/prosemirror/marks/strike.rb +5 -0
  28. data/lib/prosemirror/marks/subscript.rb +2 -0
  29. data/lib/prosemirror/marks/superscript.rb +2 -0
  30. data/lib/prosemirror/marks/underline.rb +3 -0
  31. data/lib/prosemirror/node.rb +12 -0
  32. data/lib/prosemirror/node_type.rb +22 -0
  33. data/lib/prosemirror/nodes/basic/code_block.rb +1 -0
  34. data/lib/prosemirror/nodes/basic/image.rb +1 -0
  35. data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -1
  36. data/lib/prosemirror/nodes/blockquote.rb +1 -0
  37. data/lib/prosemirror/nodes/bullet_list.rb +1 -0
  38. data/lib/prosemirror/nodes/code_block.rb +6 -1
  39. data/lib/prosemirror/nodes/hard_break.rb +2 -0
  40. data/lib/prosemirror/nodes/heading.rb +1 -0
  41. data/lib/prosemirror/nodes/horizontal_rule.rb +1 -0
  42. data/lib/prosemirror/nodes/image.rb +1 -0
  43. data/lib/prosemirror/nodes/list_item.rb +1 -0
  44. data/lib/prosemirror/nodes/ordered_list.rb +20 -2
  45. data/lib/prosemirror/nodes/paragraph.rb +1 -0
  46. data/lib/prosemirror/nodes/table.rb +1 -0
  47. data/lib/prosemirror/nodes/table_cell.rb +12 -2
  48. data/lib/prosemirror/nodes/table_header.rb +2 -0
  49. data/lib/prosemirror/nodes/table_row.rb +1 -0
  50. data/lib/prosemirror/parse_rule.rb +50 -0
  51. data/lib/prosemirror/schema.rb +87 -15
  52. data/lib/prosemirror/type_definition.rb +34 -4
  53. data/lib/prosemirror/utf8.rb +16 -0
  54. data/lib/prosemirror/version.rb +1 -1
  55. data/lib/prosemirror.rb +22 -1
  56. metadata +15 -1
@@ -68,18 +68,7 @@ module Prosemirror
68
68
  nodes = content.each_with_index.filter_map do |child, index|
69
69
  build_node(child, "#{path}.content[#{index}]", depth + 1)
70
70
  end
71
- join_text(nodes)
72
- end
73
-
74
- def join_text(nodes)
75
- nodes.each_with_object([]) do |node, joined|
76
- previous = joined.last
77
- if previous&.text? && node.text? && previous.marks == node.marks
78
- joined[-1] = previous.with(text: previous.text + node.text)
79
- else
80
- joined << node
81
- end
82
- end
71
+ Node.join_text(nodes)
83
72
  end
84
73
 
85
74
  def build_marks(marks, path)
@@ -27,6 +27,17 @@ module Prosemirror
27
27
  inherited_setting(:@excludes, nil)
28
28
  end
29
29
 
30
+ # Whether this mark can't coexist with marks of the other type.
31
+ def excludes?(other)
32
+ case excludes
33
+ when nil then other.type_name == type_name
34
+ when "_" then true
35
+ else
36
+ names = excludes.split.map(&:to_sym)
37
+ names.include?(other.type_name) || names.intersect?(other.group)
38
+ end
39
+ end
40
+
30
41
  def group(*names)
31
42
  return @group = names.flat_map { |n| n.to_s.split }.map(&:to_sym) unless names.empty?
32
43
 
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Markdown
5
+ # Builds the Elements and Texts of HTML import from Markdown, with commonmarker's syntax tree
6
+ # (https://github.com/gjtorikian/commonmarker), so that the schema's parse rules apply to Markdown too.
7
+ # Each Markdown node becomes the element commonmarker renders for it, without going through HTML.
8
+ #
9
+ # - GitHub Flavored Markdown: tables, strikethrough and autolinks. Task list items stay as text (`[ ] `).
10
+ # - Raw HTML can span several Markdown blocks, like `<del>` around a paragraph: with the herb gem, a
11
+ # document holding raw HTML is rendered to HTML by commonmarker and imported. Without it, raw HTML
12
+ # stays as text.
13
+ # - Code blocks lose their final newline, as in prosemirror-markdown.
14
+ # - YAML front matter, between `---` lines at the start, is dropped.
15
+ class Builder
16
+ MAX_DEPTH = HTML::Builder::MAX_DEPTH
17
+ EXTENSIONS = {
18
+ strikethrough: true, table: true, autolink: true, tasklist: false, shortcodes: false, header_ids: nil,
19
+ front_matter_delimiter: "---"
20
+ }.freeze
21
+ RENDER_OPTIONS = {extension: EXTENSIONS, render: {unsafe: true, hardbreaks: false, github_pre_lang: false}}.freeze
22
+ TAGS = {
23
+ paragraph: "p", block_quote: "blockquote", item: "li", thematic_break: "hr", table_row: "tr",
24
+ emph: "em", strong: "strong", strikethrough: "s", linebreak: "br"
25
+ }.freeze
26
+ # A table's delimiter row, like `| :-- | --: |`, at the end of its line.
27
+ DELIMITER_ROW = /\|?(?:\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\s*\z/
28
+ # The bytes commonmarker escapes in URLs, as comrak's `escape_href`: all but these, and `%` when it
29
+ # doesn't start an escape. `&` and `'` are kept: comrak writes them as HTML references.
30
+ URL_UNSAFE = %r{%(?!\h\h)|[^-_.+!*(),#@?=;:/$~A-Za-z0-9&'%]}n
31
+ # An IPv6 host at the start of a URL, like `http://[::1]`, kept as it is.
32
+ IPV6_HOST = %r{\A[A-Za-z][A-Za-z0-9+.-]*://\[[\h:.]+\]}
33
+
34
+ def self.load_commonmarker
35
+ require "commonmarker"
36
+ rescue LoadError
37
+ raise Error, 'Markdown import needs the commonmarker gem: add `gem "commonmarker"` to your Gemfile'
38
+ end
39
+
40
+ # A root Element holding the content.
41
+ def call(markdown)
42
+ self.class.load_commonmarker
43
+ markdown = UTF8.string(markdown)
44
+ document = parse(markdown)
45
+ return from_html(document) if raw_html?(document) && HTML::Builder.herb?
46
+
47
+ @lines = markdown.lines
48
+ root = HTML::Element.new("#document")
49
+ build(document, root)
50
+ root
51
+ end
52
+
53
+ private
54
+
55
+ def parse(markdown)
56
+ Commonmarker.parse(markdown, options: {extension: EXTENSIONS})
57
+ rescue Commonmarker::NestingDepthExceededError => error
58
+ raise Error, "Markdown nested too deeply: #{error.message}"
59
+ end
60
+
61
+ # Walks the tree without recursion. Each item of the stack is a node, the element to add it to, and
62
+ # the depth of that element. Elements nested deeper than MAX_DEPTH are flattened.
63
+ def build(document, root)
64
+ stack = document.to_a.reverse.map { |node| [node, root, 0] }
65
+ until stack.empty?
66
+ node, target, depth = stack.pop
67
+ element = add(node, target)
68
+ next unless element
69
+
70
+ if depth >= MAX_DEPTH
71
+ element = target
72
+ else
73
+ target.append(element)
74
+ end
75
+ node.to_a.reverse_each { |child| stack << [child, element, element.equal?(target) ? depth : depth + 1] }
76
+ end
77
+ end
78
+
79
+ # Adds a node to the target. Returns the element to add its children to, when they need adding.
80
+ def add(node, target)
81
+ case node.type
82
+ when *TAGS.keys then HTML::Element.new(TAGS[node.type])
83
+ when :heading then HTML::Element.new("h#{node.header_level}")
84
+ when :list then list(node)
85
+ when :code_block then code_block(node, target)
86
+ when :table then table(node)
87
+ when :table_cell then table_cell(node)
88
+ when :link then HTML::Element.new("a", {"href" => escape_url(node.url), "title" => presence(node.title)}.compact)
89
+ when :image then image(node, target)
90
+ when :code then text(target, node.string_content, "code")
91
+ when :text then text(target, node.string_content)
92
+ when :softbreak then text(target, "\n")
93
+ when :html_block then text(target.append(HTML::Element.new("p")), node.literal.chomp)
94
+ when :html_inline then text(target, node.literal)
95
+ when :frontmatter then nil
96
+ else HTML::Element.new("span") # Unwrapped by HTML import, like escaped characters (`\*`).
97
+ end
98
+ end
99
+
100
+ def list(node)
101
+ return HTML::Element.new("ul") unless node.list_type == :ordered
102
+
103
+ HTML::Element.new("ol", (node.list_start == 1) ? {} : {"start" => node.list_start.to_s})
104
+ end
105
+
106
+ def code_block(node, target)
107
+ language = node.fence_info.to_s.split.first
108
+ code = HTML::Element.new("code", language ? {"class" => "language-#{language}"} : {})
109
+ code.append(HTML::Text.new(node.string_content.chomp))
110
+ target.append(HTML::Element.new("pre")).append(code)
111
+ nil
112
+ end
113
+
114
+ def table(node)
115
+ @alignments = alignments(node)
116
+ @row = 0
117
+ HTML::Element.new("table")
118
+ end
119
+
120
+ # The first row holds header cells.
121
+ def table_cell(node)
122
+ index = cell_index(node)
123
+ @row += 1 if index.zero?
124
+ align = @alignments[index]
125
+ HTML::Element.new((@row == 1) ? "th" : "td", align ? {"style" => "text-align: #{align}"} : {})
126
+ end
127
+
128
+ def cell_index(node)
129
+ index = 0
130
+ index += 1 while (node = node.previous_sibling)
131
+ index
132
+ end
133
+
134
+ # commonmarker doesn't give the alignments of a table's columns: they are read from its delimiter row,
135
+ # the line after its first line.
136
+ def alignments(node)
137
+ row = @lines[node.source_position[:start_line]].to_s.chomp[DELIMITER_ROW].to_s
138
+ row.strip.delete_prefix("|").delete_suffix("|").split("|").map do |cell|
139
+ cell = cell.strip
140
+ if cell.start_with?(":") && cell.end_with?(":") then "center"
141
+ elsif cell.end_with?(":") then "right"
142
+ elsif cell.start_with?(":") then "left"
143
+ end
144
+ end
145
+ end
146
+
147
+ def image(node, target)
148
+ attributes = {"src" => escape_url(node.url), "alt" => plain_text(node), "title" => presence(node.title)}.compact
149
+ target.append(HTML::Element.new("img", attributes))
150
+ nil
151
+ end
152
+
153
+ def text(target, value, tag = nil)
154
+ target = target.append(HTML::Element.new(tag)) if tag
155
+ target.append(HTML::Text.new(value))
156
+ nil
157
+ end
158
+
159
+ def raw_html?(document)
160
+ document.walk { |node| return true if node.type == :html_block || node.type == :html_inline }
161
+ false
162
+ end
163
+
164
+ def from_html(document)
165
+ root = HTML::Builder.new.call(document.to_html(options: RENDER_OPTIONS, plugins: {syntax_highlighter: nil}))
166
+ chomp_code_blocks(root)
167
+ root
168
+ end
169
+
170
+ # The code blocks commonmarker renders, `<pre><code>`, not the `<pre>` elements of raw HTML.
171
+ def chomp_code_blocks(element)
172
+ element.children.each do |child|
173
+ next unless child.element?
174
+ next chomp_code_blocks(child) unless child.name == "pre"
175
+
176
+ code = child.children.first
177
+ next unless code&.element? && code.name == "code" && child.children.size == 1
178
+
179
+ last = code.children.last
180
+ code.children[-1] = HTML::Text.new(last.value.chomp).tap { |text| text.parent = code } if last.is_a?(HTML::Text)
181
+ end
182
+ end
183
+
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
190
+
191
+ def plain_text(node)
192
+ node.to_a.map { |child| %i[text code].include?(child.type) ? child.string_content : plain_text(child) }.join
193
+ end
194
+
195
+ def presence(value)
196
+ value unless value.nil? || value.empty?
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ module Basic
6
+ # ProseMirror's inline code, which can be combined with other marks.
7
+ class Code < MarkType
8
+ code
9
+
10
+ parse_html tag: "code"
11
+
12
+ def view_template(&) = code(&)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -5,6 +5,10 @@ module Prosemirror
5
5
  module Basic
6
6
  class Em < MarkType
7
7
  aliases :italic
8
+ parse_html tag: "i"
9
+ parse_html tag: "em"
10
+ parse_html style: "font-style=italic"
11
+ parse_html style: "font-style=normal", clear_mark: true
8
12
 
9
13
  def view_template(&) = em(&)
10
14
  end
@@ -7,6 +7,7 @@ module Prosemirror
7
7
  class Link < MarkType
8
8
  prop :href, _String(length: 1..)
9
9
  prop :title, _String?
10
+ parse_html(tag: "a[href]") { |element, schema| schema.allowed_url?(element["href"]) && {} }
10
11
 
11
12
  def view_template(&)
12
13
  href = safe_url(@href)
@@ -5,6 +5,10 @@ module Prosemirror
5
5
  module Basic
6
6
  class Strong < MarkType
7
7
  aliases :bold
8
+ parse_html tag: "strong"
9
+ parse_html(tag: "b") { |element| element.style["font-weight"] != "normal" && {} }
10
+ parse_html style: "font-weight=400", clear_mark: true
11
+ parse_html(style: "font-weight") { |value| value.match?(Bold::BOLD) && {} }
8
12
 
9
13
  def view_template(&) = strong(&)
10
14
  end
@@ -3,8 +3,16 @@
3
3
  module Prosemirror
4
4
  module Marks
5
5
  class Bold < MarkType
6
+ # The `font-weight` values that mean bold.
7
+ BOLD = /\A(bold(er)?|[5-9]\d{2,})\z/
8
+
6
9
  aliases :strong
7
10
 
11
+ parse_html tag: "strong"
12
+ parse_html(tag: "b") { |element| element.style["font-weight"] != "normal" && {} }
13
+ parse_html style: "font-weight=400", clear_mark: true
14
+ parse_html(style: "font-weight") { |value| value.match?(BOLD) && {} }
15
+
8
16
  def view_template(&) = strong(&)
9
17
  end
10
18
  end
@@ -2,8 +2,13 @@
2
2
 
3
3
  module Prosemirror
4
4
  module Marks
5
+ # Tiptap's inline code, which can't be combined with other marks.
5
6
  class Code < MarkType
6
7
  code
8
+ excludes "_"
9
+
10
+ parse_html tag: "code"
11
+ parse_html tag: "tt"
7
12
 
8
13
  def view_template(&) = code(&)
9
14
  end
@@ -6,7 +6,9 @@ module Prosemirror
6
6
  class Highlight < MarkType
7
7
  COLOR = %r{\A(\#\h{3,8}|[a-z]+|(rgb|hsl)a?\([\d\s.,%/]+\))\z}i
8
8
 
9
- prop :color, _String?(COLOR)
9
+ prop :color, _String?(COLOR), parse_html: ->(element) { element["data-color"] || element.style["background-color"] }
10
+
11
+ parse_html tag: "mark"
10
12
 
11
13
  def view_template(&)
12
14
  mark(data_color: @color, style: @color && "background-color: #{@color}; color: inherit", &)
@@ -4,6 +4,10 @@ module Prosemirror
4
4
  module Marks
5
5
  class Italic < MarkType
6
6
  aliases :em
7
+ parse_html tag: "em"
8
+ parse_html(tag: "i") { |element| element.style["font-style"] != "normal" && {} }
9
+ parse_html style: "font-style=normal", clear_mark: true
10
+ parse_html style: "font-style=italic"
7
11
 
8
12
  def view_template(&) = em(&)
9
13
  end
@@ -9,6 +9,7 @@ module Prosemirror
9
9
  prop :rel, _String?
10
10
  prop :css_class, _String?, json: "class"
11
11
  prop :title, _String?
12
+ parse_html(tag: "a[href]") { |element, schema| schema.allowed_url?(element["href"]) && {} }
12
13
 
13
14
  def view_template(&)
14
15
  href = safe_url(@href)
@@ -3,6 +3,11 @@
3
3
  module Prosemirror
4
4
  module Marks
5
5
  class Strike < MarkType
6
+ parse_html tag: "s"
7
+ parse_html tag: "del"
8
+ parse_html tag: "strike"
9
+ parse_html(style: "text-decoration", consuming: false) { |value| value.include?("line-through") ? {} : false }
10
+
6
11
  def view_template(&) = s(&)
7
12
  end
8
13
  end
@@ -4,6 +4,8 @@ module Prosemirror
4
4
  module Marks
5
5
  class Subscript < MarkType
6
6
  excludes "superscript"
7
+ parse_html tag: "sub"
8
+ parse_html(style: "vertical-align") { |value| (value == "sub") ? {} : false }
7
9
 
8
10
  def view_template(&) = sub(&)
9
11
  end
@@ -4,6 +4,8 @@ module Prosemirror
4
4
  module Marks
5
5
  class Superscript < MarkType
6
6
  excludes "subscript"
7
+ parse_html tag: "sup"
8
+ parse_html(style: "vertical-align") { |value| (value == "super") ? {} : false }
7
9
 
8
10
  def view_template(&) = sup(&)
9
11
  end
@@ -3,6 +3,9 @@
3
3
  module Prosemirror
4
4
  module Marks
5
5
  class Underline < MarkType
6
+ parse_html tag: "u"
7
+ parse_html(style: "text-decoration", consuming: false) { |value| value.include?("underline") ? {} : false }
8
+
6
9
  def view_template(&) = u(&)
7
10
  end
8
11
  end
@@ -18,6 +18,18 @@ module Prosemirror
18
18
  prop :marks, _Array(Mark), default: [].freeze, &Coerce::FROZEN
19
19
  prop :text, _String?, &Coerce::FROZEN
20
20
 
21
+ # The nodes with adjacent text nodes that have the same marks joined, as in ProseMirror.
22
+ def self.join_text(nodes)
23
+ nodes.each_with_object([]) do |node, joined|
24
+ previous = joined.last
25
+ if previous&.text? && node.text? && previous.marks == node.marks
26
+ joined[-1] = previous.with(text: previous.text + node.text)
27
+ else
28
+ joined << node
29
+ end
30
+ end
31
+ end
32
+
21
33
  # A copy with some properties changed.
22
34
  def with(**changes)
23
35
  self.class.new(**to_h, **changes)
@@ -86,6 +86,28 @@ module Prosemirror
86
86
  value = leaf_text
87
87
  value.respond_to?(:call) ? value.call(node).to_s : value.to_s
88
88
  end
89
+
90
+ # This inline node stands for a newline, as ProseMirror's `linebreakReplacement`: when importing
91
+ # HTML whose whitespace is preserved, newlines become nodes of this type.
92
+ def linebreak_replacement(value = true)
93
+ @linebreak_replacement = value
94
+ end
95
+
96
+ def linebreak_replacement?
97
+ inherited_setting(:@linebreak_replacement, false)
98
+ end
99
+
100
+ # Whether marks of this type can be applied inside this node, according to its `marks` option.
101
+ def allows_mark?(mark_type, schema)
102
+ case marks
103
+ when nil then schema.content_match(self).inline_content?
104
+ when "_" then true
105
+ when "" then false
106
+ else
107
+ names = marks.split.map(&:to_sym)
108
+ names.include?(mark_type.type_name) || names.intersect?(mark_type.group)
109
+ end
110
+ end
89
111
  end
90
112
 
91
113
  # The node being rendered.
@@ -9,6 +9,7 @@ module Prosemirror
9
9
  content "text*"
10
10
  marks ""
11
11
  code
12
+ parse_html tag: "pre", preserve_whitespace: :full
12
13
 
13
14
  def view_template(&)
14
15
  pre { code(&) }
@@ -11,6 +11,7 @@ module Prosemirror
11
11
  prop :src, _String(length: 1..)
12
12
  prop :alt, _String?
13
13
  prop :title, _String?
14
+ parse_html tag: "img[src]"
14
15
 
15
16
  def view_template
16
17
  src = safe_url(@src)
@@ -8,7 +8,10 @@ module Prosemirror
8
8
  group :block, :list
9
9
  content "list_item+"
10
10
 
11
- prop :order, Integer, default: 1, aliases: ["start"], &Coerce::INTEGER
11
+ prop :order, Integer, default: 1, aliases: ["start"],
12
+ parse_html: ->(element) { element.key?("start") ? element["start"].to_i : 1 }, &Coerce::INTEGER
13
+
14
+ parse_html tag: "ol"
12
15
 
13
16
  def view_template(&)
14
17
  ol(start: (@order unless @order == 1), &)
@@ -5,6 +5,7 @@ module Prosemirror
5
5
  class Blockquote < NodeType
6
6
  group :block
7
7
  content "block+"
8
+ parse_html tag: "blockquote"
8
9
 
9
10
  def view_template(&) = blockquote(&)
10
11
  end
@@ -5,6 +5,7 @@ module Prosemirror
5
5
  class BulletList < NodeType
6
6
  group :block, :list
7
7
  content "list_item+"
8
+ parse_html tag: "ul"
8
9
 
9
10
  def view_template(&) = ul(&)
10
11
  end
@@ -9,7 +9,12 @@ module Prosemirror
9
9
  marks ""
10
10
  code
11
11
 
12
- prop :language, _String?(/\A[\w#+.-]+\z/), aliases: ["params"]
12
+ # From the `language-*` class of the `<code>` inside `<pre>`.
13
+ prop :language, _String?(/\A[\w#+.-]+\z/), aliases: ["params"], parse_html: lambda { |element|
14
+ element.first_element_child&.classes&.find { |name| name.start_with?("language-") }&.delete_prefix("language-")
15
+ }
16
+
17
+ parse_html tag: "pre", preserve_whitespace: :full
13
18
 
14
19
  def view_template(&)
15
20
  pre { code(class: @language && "language-#{@language}", &) }
@@ -6,6 +6,8 @@ module Prosemirror
6
6
  group :inline
7
7
  inline
8
8
  leaf_text "\n"
9
+ linebreak_replacement
10
+ parse_html tag: "br"
9
11
 
10
12
  def view_template = br
11
13
  end
@@ -7,6 +7,7 @@ module Prosemirror
7
7
  content "inline*"
8
8
 
9
9
  prop :level, _Integer(1..6), default: 1, &Coerce::INTEGER
10
+ (1..6).each { |level| parse_html tag: "h#{level}", attrs: {level:} }
10
11
 
11
12
  def view_template(&) = tag(:"h#{@level}", &)
12
13
  end
@@ -4,6 +4,7 @@ module Prosemirror
4
4
  module Nodes
5
5
  class HorizontalRule < NodeType
6
6
  group :block
7
+ parse_html tag: "hr"
7
8
 
8
9
  def view_template = hr
9
10
  end
@@ -11,6 +11,7 @@ module Prosemirror
11
11
  prop :title, _String?
12
12
  prop :width, _Integer?(1..), &Coerce::INTEGER
13
13
  prop :height, _Integer?(1..), &Coerce::INTEGER
14
+ parse_html tag: %(img[src]:not([src^="data:"]))
14
15
 
15
16
  def view_template
16
17
  src = safe_url(@src)
@@ -4,6 +4,7 @@ module Prosemirror
4
4
  module Nodes
5
5
  class ListItem < NodeType
6
6
  content "paragraph block*"
7
+ parse_html tag: "li"
7
8
 
8
9
  def view_template(&) = li(&)
9
10
  end
@@ -4,11 +4,29 @@ module Prosemirror
4
4
  module Nodes
5
5
  # Tiptap's ordered list. ProseMirror's `order` attribute is read as `start`.
6
6
  class OrderedList < NodeType
7
+ # CSS list styles, used by Google Docs and Word, and their `type`.
8
+ LIST_STYLE_TYPES = {
9
+ "upper-roman" => "I", "lower-roman" => "i", "upper-alpha" => "A", "upper-latin" => "A",
10
+ "lower-alpha" => "a", "lower-latin" => "a"
11
+ }.freeze
12
+
7
13
  group :block, :list
8
14
  content "list_item+"
9
15
 
10
- prop :start, Integer, default: 1, aliases: ["order"], &Coerce::INTEGER
11
- prop :type, _Union?("1", "a", "A", "i", "I")
16
+ prop :start, Integer, default: 1, aliases: ["order"],
17
+ parse_html: ->(element) { element.key?("start") ? element["start"].to_i : 1 }, &Coerce::INTEGER
18
+ prop :type, _Union?("1", "a", "A", "i", "I"), parse_html: ->(element) { OrderedList.type_from_html(element) }
19
+
20
+ parse_html tag: "ol"
21
+
22
+ # From the `type` attribute, or the list style of the list or of its first item.
23
+ def self.type_from_html(element)
24
+ element["type"] || list_style_type(element) || element.query("li")&.then { |item| list_style_type(item) }
25
+ end
26
+
27
+ def self.list_style_type(element)
28
+ LIST_STYLE_TYPES[element.style["list-style-type"].to_s.downcase]
29
+ end
12
30
 
13
31
  def view_template(&)
14
32
  ol(start: (@start unless @start == 1), type: @type, &)
@@ -5,6 +5,7 @@ module Prosemirror
5
5
  class Paragraph < NodeType
6
6
  group :block
7
7
  content "inline*"
8
+ parse_html tag: "p"
8
9
 
9
10
  def view_template(&) = p(&)
10
11
  end
@@ -5,6 +5,7 @@ module Prosemirror
5
5
  class Table < NodeType
6
6
  group :block
7
7
  content "table_row+"
8
+ parse_html tag: "table"
8
9
 
9
10
  def view_template(&)
10
11
  table { tbody(&) }
@@ -3,11 +3,20 @@
3
3
  module Prosemirror
4
4
  module Nodes
5
5
  class TableCell < NodeType
6
+ ALIGNMENTS = %w[left right center].freeze
7
+
6
8
  content "block+"
7
9
 
8
10
  prop :colspan, _Integer(1..), default: 1, &Coerce::INTEGER
9
11
  prop :rowspan, _Integer(1..), default: 1, &Coerce::INTEGER
10
- prop :colwidth, _Array?(_Integer(1..))
12
+ prop :colwidth, _Array?(_Integer(1..)),
13
+ parse_html: ->(element) { element["colwidth"]&.split(",")&.map(&:to_i) }
14
+ # From the `text-align` style, or the old `align` attribute.
15
+ prop :align, _Union?(*ALIGNMENTS), parse_html: lambda { |element|
16
+ (element.style["text-align"] || element["align"]).to_s.strip.downcase.then { |value| value if ALIGNMENTS.include?(value) }
17
+ }
18
+
19
+ parse_html tag: "td"
11
20
 
12
21
  def view_template(&)
13
22
  td(**cell_attributes, &)
@@ -16,7 +25,8 @@ module Prosemirror
16
25
  private
17
26
 
18
27
  def cell_attributes
19
- {colspan: (@colspan unless @colspan == 1), rowspan: (@rowspan unless @rowspan == 1)}
28
+ {colspan: (@colspan unless @colspan == 1), rowspan: (@rowspan unless @rowspan == 1),
29
+ style: @align && "text-align: #{@align}"}
20
30
  end
21
31
  end
22
32
  end
@@ -5,6 +5,8 @@ module Prosemirror
5
5
  class TableHeader < TableCell
6
6
  type_name :table_header
7
7
 
8
+ parse_html tag: "th"
9
+
8
10
  def view_template(&)
9
11
  th(**cell_attributes, &)
10
12
  end
@@ -4,6 +4,7 @@ module Prosemirror
4
4
  module Nodes
5
5
  class TableRow < NodeType
6
6
  content "(table_cell | table_header)*"
7
+ parse_html tag: "tr"
7
8
 
8
9
  def view_template(&) = tr(&)
9
10
  end