prosemirror 0.1.0 → 0.2.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +31 -45
  4. data/ROADMAP.md +10 -6
  5. data/TYPES.md +201 -0
  6. data/lib/prosemirror/content_match.rb +241 -0
  7. data/lib/prosemirror/html/builder.rb +146 -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 +498 -0
  15. data/lib/prosemirror/json_parser.rb +1 -12
  16. data/lib/prosemirror/mark_type.rb +11 -0
  17. data/lib/prosemirror/marks/basic/code.rb +16 -0
  18. data/lib/prosemirror/marks/basic/em.rb +4 -0
  19. data/lib/prosemirror/marks/basic/link.rb +1 -0
  20. data/lib/prosemirror/marks/basic/strong.rb +4 -0
  21. data/lib/prosemirror/marks/bold.rb +8 -0
  22. data/lib/prosemirror/marks/code.rb +5 -0
  23. data/lib/prosemirror/marks/highlight.rb +3 -1
  24. data/lib/prosemirror/marks/italic.rb +4 -0
  25. data/lib/prosemirror/marks/link.rb +1 -0
  26. data/lib/prosemirror/marks/strike.rb +5 -0
  27. data/lib/prosemirror/marks/subscript.rb +2 -0
  28. data/lib/prosemirror/marks/superscript.rb +2 -0
  29. data/lib/prosemirror/marks/underline.rb +3 -0
  30. data/lib/prosemirror/node.rb +12 -0
  31. data/lib/prosemirror/node_type.rb +22 -0
  32. data/lib/prosemirror/nodes/basic/code_block.rb +1 -0
  33. data/lib/prosemirror/nodes/basic/image.rb +1 -0
  34. data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -1
  35. data/lib/prosemirror/nodes/blockquote.rb +1 -0
  36. data/lib/prosemirror/nodes/bullet_list.rb +1 -0
  37. data/lib/prosemirror/nodes/code_block.rb +6 -1
  38. data/lib/prosemirror/nodes/hard_break.rb +2 -0
  39. data/lib/prosemirror/nodes/heading.rb +1 -0
  40. data/lib/prosemirror/nodes/horizontal_rule.rb +1 -0
  41. data/lib/prosemirror/nodes/image.rb +1 -0
  42. data/lib/prosemirror/nodes/list_item.rb +1 -0
  43. data/lib/prosemirror/nodes/ordered_list.rb +20 -2
  44. data/lib/prosemirror/nodes/paragraph.rb +1 -0
  45. data/lib/prosemirror/nodes/table.rb +1 -0
  46. data/lib/prosemirror/nodes/table_cell.rb +12 -2
  47. data/lib/prosemirror/nodes/table_header.rb +2 -0
  48. data/lib/prosemirror/nodes/table_row.rb +1 -0
  49. data/lib/prosemirror/parse_rule.rb +50 -0
  50. data/lib/prosemirror/schema.rb +66 -15
  51. data/lib/prosemirror/type_definition.rb +34 -4
  52. data/lib/prosemirror/version.rb +1 -1
  53. data/lib/prosemirror.rb +11 -1
  54. metadata +13 -1
@@ -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,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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # How to recognize a node or a mark in HTML, as ProseMirror's ParseRule:
5
+ # https://prosemirror.net/docs/ref/#model.ParseRule
6
+ #
7
+ # - `tag`: a CSS selector the element must match (see HTML::Selector), or
8
+ # - `style`: a CSS property (`"font-style"`), or a property and value (`"font-style=italic"`), for marks;
9
+ # - `attrs`: attributes to give the node or mark;
10
+ # - `get_attrs`: a block called with the element (or the style's value), returning attributes, nil for
11
+ # the defaults, or false when the rule doesn't match;
12
+ # - `priority` (default 50): rules with a higher priority are tried first;
13
+ # - `consuming` (default true): false lets the following rules match the element too;
14
+ # - `context`: the parents the rule needs, as `"list_item/"` or `"blockquote//"`;
15
+ # - `ignore`: skip the element and its content;
16
+ # - `skip`: skip the element, but parse its content;
17
+ # - `close_parent`: close the current node;
18
+ # - `content_element`: a selector for the descendant holding the content;
19
+ # - `get_content`: a block called with the element and the schema, returning the nodes to use as content;
20
+ # - `preserve_whitespace`: true to keep spaces, `:full` to also keep newlines;
21
+ # - `clear_mark`: for style rules, true to remove the type's marks, or a block called with each mark.
22
+ ParseRule = Data.define(
23
+ :type, :tag, :style, :attrs, :get_attrs, :priority, :consuming, :context, :ignore, :skip,
24
+ :close_parent, :content_element, :get_content, :preserve_whitespace, :clear_mark
25
+ ) do
26
+ def initialize(type:, tag: nil, style: nil, attrs: nil, get_attrs: nil, priority: 50, consuming: true,
27
+ context: nil, ignore: false, skip: false, close_parent: false, content_element: nil, get_content: nil,
28
+ preserve_whitespace: nil, clear_mark: nil)
29
+ raise ArgumentError, "a parse rule needs a tag or a style" if tag.nil? == style.nil?
30
+
31
+ HTML::Selector.parse(tag) if tag
32
+ super
33
+ end
34
+
35
+ def node?
36
+ type < NodeType
37
+ end
38
+
39
+ # The style's property, without the value.
40
+ def style_property
41
+ style&.split("=", 2)&.first
42
+ end
43
+
44
+ # Whether a style rule applies to this property and value.
45
+ def style_matches?(property, value)
46
+ name, expected = style.split("=", 2)
47
+ name == property && (expected.nil? || expected == value)
48
+ end
49
+ end
50
+ end
@@ -32,8 +32,9 @@ module Prosemirror
32
32
  Nodes::OrderedList, Nodes::ListItem, Nodes::CodeBlock, Nodes::HardBreak, Nodes::HorizontalRule,
33
33
  Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell
34
34
  ],
35
+ # In Tiptap's order, which sorts and nests marks.
35
36
  marks: [
36
- Marks::Link, Marks::Bold, Marks::Italic, Marks::Underline, Marks::Strike, Marks::Code,
37
+ Marks::Link, Marks::Bold, Marks::Code, Marks::Italic, Marks::Strike, Marks::Underline,
37
38
  Marks::Subscript, Marks::Superscript, Marks::Highlight
38
39
  ],
39
40
  **
@@ -48,7 +49,7 @@ module Prosemirror
48
49
  Nodes::Basic::CodeBlock, Nodes::Text, Nodes::Basic::Image, Nodes::HardBreak, Nodes::Basic::OrderedList,
49
50
  Nodes::BulletList, Nodes::ListItem
50
51
  ],
51
- marks: [Marks::Basic::Link, Marks::Basic::Em, Marks::Basic::Strong, Marks::Code],
52
+ marks: [Marks::Basic::Link, Marks::Basic::Em, Marks::Basic::Strong, Marks::Basic::Code],
52
53
  **
53
54
  )
54
55
  end
@@ -57,9 +58,32 @@ module Prosemirror
57
58
  @node_lookup = lookup(@nodes)
58
59
  @mark_lookup = lookup(@marks)
59
60
  @textblocks = @nodes.to_h { |type| [type, textblock_type?(type)] }
61
+ @content_matches = @nodes.to_h { |type| [type, ContentMatch.compile(type.content, self)] }
60
62
  raise ArgumentError, "the schema has no #{@top_node} node type" unless node_type(@top_node)
61
63
  end
62
64
 
65
+ # The start state of a node type's content expression.
66
+ def content_match(type)
67
+ @content_matches.fetch(type)
68
+ end
69
+
70
+ # The node types with this name or in this group, in schema order.
71
+ def node_types_named(name)
72
+ @nodes.select { |type| type.type_name == name || type.group.include?(name) }
73
+ end
74
+
75
+ # A node of this type, created empty and filled with the content it requires, or nil when its
76
+ # type, or one it requires, has required attributes.
77
+ def create_and_fill(type)
78
+ return nil if type.required_attrs?
79
+
80
+ fill = content_match(type).fill_before([], self, to_end: true)
81
+ content = fill&.map { |child| create_and_fill(child) }
82
+ return nil if content.nil? || content.include?(nil)
83
+
84
+ Node.new(type: type.type_name, attrs: type.default_attrs, content: content)
85
+ end
86
+
63
87
  # A copy of the schema with other options.
64
88
  def with_options(**)
65
89
  self.class.new(**to_h, **)
@@ -88,11 +112,51 @@ module Prosemirror
88
112
  Document.new(JSONParser.new(self).parse(json), schema: self)
89
113
  end
90
114
 
115
+ # Build a document from HTML, with the parse rules of the schema's types. Needs the `herb` gem.
116
+ def parse_html(html)
117
+ Document.new(HTMLParser.new(self).parse(html), schema: self)
118
+ end
119
+
91
120
  # The type name as written in JSON.
92
121
  def json_type_name(name)
93
122
  (@case == :camel) ? Naming.camel(name) : name.to_s
94
123
  end
95
124
 
125
+ # The set of marks with this mark added, in schema order, as ProseMirror's `Mark#addToSet`: a mark
126
+ # replaces the marks it excludes, and isn't added when a mark of the set excludes it.
127
+ def add_mark(mark, set)
128
+ type = mark_type(mark.type)
129
+ result = []
130
+ placed = false
131
+ set.each do |other|
132
+ return set if other == mark
133
+
134
+ other_type = mark_type(other.type)
135
+ next if type.excludes?(other_type)
136
+ return set if other_type.excludes?(type)
137
+
138
+ if !placed && mark_rank(other.type) > mark_rank(mark.type)
139
+ result << mark
140
+ placed = true
141
+ end
142
+ result << other
143
+ end
144
+ result << mark unless placed
145
+ result
146
+ end
147
+
148
+ # Whether a URL's scheme is allowed, or the URL is relative. Browsers ignore leading control characters
149
+ # and spaces, and tabs and newlines anywhere in a URL.
150
+ def allowed_url?(url)
151
+ return false unless url.is_a?(String)
152
+
153
+ normalized = url.delete("\t\n\r").sub(/\A[\x00-\x20]+/, "")
154
+ scheme = normalized[%r{\A([^/?#]*):}, 1]
155
+ return true if scheme.nil?
156
+
157
+ scheme.match?(/\A[a-z][a-z\d+.-]*\z/i) && @url_schemes.include?(scheme.downcase)
158
+ end
159
+
96
160
  # The order of marks in a node. Marks of unknown types come last.
97
161
  def mark_rank(name)
98
162
  @marks.index(mark_type(name)) || @marks.size
@@ -158,19 +222,6 @@ module Prosemirror
158
222
  end
159
223
  end
160
224
 
161
- def node_types_named(name)
162
- @nodes.select { |type| type.type_name == name || type.group.include?(name) }
163
- end
164
-
165
- # Browsers ignore leading control characters and spaces, and tabs and newlines anywhere in a URL.
166
- def allowed_url?(url)
167
- normalized = url.delete("\t\n\r").sub(/\A[\x00-\x20]+/, "")
168
- scheme = normalized[%r{\A([^/?#]*):}, 1]
169
- return true if scheme.nil?
170
-
171
- scheme.match?(/\A[a-z][a-z\d+.-]*\z/i) && @url_schemes.include?(scheme.downcase)
172
- end
173
-
174
225
  # Declared attributes with their JSON names, then the unknown ones, as they were.
175
226
  def serialize_node_attrs(node)
176
227
  type = node_type(node.type)