prosemirror 0.0.1 → 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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/COOKBOOK.md +108 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +155 -12
  6. data/ROADMAP.md +215 -0
  7. data/TYPES.md +201 -0
  8. data/lib/prosemirror/coerce.rb +17 -0
  9. data/lib/prosemirror/content_expression.rb +94 -0
  10. data/lib/prosemirror/content_match.rb +241 -0
  11. data/lib/prosemirror/document.rb +76 -0
  12. data/lib/prosemirror/html/builder.rb +146 -0
  13. data/lib/prosemirror/html/element.rb +84 -0
  14. data/lib/prosemirror/html/entities.rb +87 -0
  15. data/lib/prosemirror/html/entities.txt +2231 -0
  16. data/lib/prosemirror/html/selector.rb +95 -0
  17. data/lib/prosemirror/html/splitter.rb +68 -0
  18. data/lib/prosemirror/html/text.rb +23 -0
  19. data/lib/prosemirror/html_parser.rb +498 -0
  20. data/lib/prosemirror/html_renderer.rb +20 -0
  21. data/lib/prosemirror/json_parser.rb +114 -0
  22. data/lib/prosemirror/mark.rb +19 -0
  23. data/lib/prosemirror/mark_type.rb +71 -0
  24. data/lib/prosemirror/marks/basic/code.rb +16 -0
  25. data/lib/prosemirror/marks/basic/em.rb +17 -0
  26. data/lib/prosemirror/marks/basic/link.rb +21 -0
  27. data/lib/prosemirror/marks/basic/strong.rb +17 -0
  28. data/lib/prosemirror/marks/bold.rb +19 -0
  29. data/lib/prosemirror/marks/code.rb +16 -0
  30. data/lib/prosemirror/marks/highlight.rb +18 -0
  31. data/lib/prosemirror/marks/italic.rb +15 -0
  32. data/lib/prosemirror/marks/link.rb +22 -0
  33. data/lib/prosemirror/marks/strike.rb +14 -0
  34. data/lib/prosemirror/marks/subscript.rb +13 -0
  35. data/lib/prosemirror/marks/superscript.rb +13 -0
  36. data/lib/prosemirror/marks/underline.rb +12 -0
  37. data/lib/prosemirror/naming.rb +16 -0
  38. data/lib/prosemirror/node.rb +77 -0
  39. data/lib/prosemirror/node_type.rb +123 -0
  40. data/lib/prosemirror/nodes/basic/code_block.rb +20 -0
  41. data/lib/prosemirror/nodes/basic/image.rb +23 -0
  42. data/lib/prosemirror/nodes/basic/ordered_list.rb +22 -0
  43. data/lib/prosemirror/nodes/blockquote.rb +13 -0
  44. data/lib/prosemirror/nodes/bullet_list.rb +13 -0
  45. data/lib/prosemirror/nodes/code_block.rb +24 -0
  46. data/lib/prosemirror/nodes/doc.rb +10 -0
  47. data/lib/prosemirror/nodes/hard_break.rb +15 -0
  48. data/lib/prosemirror/nodes/heading.rb +15 -0
  49. data/lib/prosemirror/nodes/horizontal_rule.rb +12 -0
  50. data/lib/prosemirror/nodes/image.rb +22 -0
  51. data/lib/prosemirror/nodes/list_item.rb +12 -0
  52. data/lib/prosemirror/nodes/ordered_list.rb +36 -0
  53. data/lib/prosemirror/nodes/paragraph.rb +13 -0
  54. data/lib/prosemirror/nodes/table.rb +15 -0
  55. data/lib/prosemirror/nodes/table_cell.rb +33 -0
  56. data/lib/prosemirror/nodes/table_header.rb +15 -0
  57. data/lib/prosemirror/nodes/table_row.rb +12 -0
  58. data/lib/prosemirror/nodes/text.rb +10 -0
  59. data/lib/prosemirror/parse_rule.rb +50 -0
  60. data/lib/prosemirror/rendering.rb +70 -0
  61. data/lib/prosemirror/schema.rb +241 -0
  62. data/lib/prosemirror/text_serializer.rb +46 -0
  63. data/lib/prosemirror/type_definition.rb +149 -0
  64. data/lib/prosemirror/version.rb +1 -1
  65. data/lib/prosemirror.rb +43 -2
  66. metadata +113 -16
  67. data/.rubocop.yml +0 -13
  68. data/Gemfile +0 -12
  69. data/Rakefile +0 -16
  70. data/prosemirror.gemspec +0 -35
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module HTML
5
+ # Builds Elements and Texts from HTML, with Herb's parser (https://herb-tools.dev), which never raises
6
+ # on malformed HTML. It also does what Herb leaves to its users:
7
+ # - decodes character references, in text and attribute values;
8
+ # - keeps ERB tags (`<% %>`) as text;
9
+ # - applies an opening tag without a closing tag to the rest of its parent, as browsers do;
10
+ # - drops comments (including Word's `<![if …]>`), doctypes and CDATA;
11
+ # - walks the tree without recursion, and flattens elements nested deeper than MAX_DEPTH.
12
+ class Builder
13
+ MAX_DEPTH = 256
14
+ # The size of the pieces Herb parses, in bytes. See Splitter.
15
+ PIECE_SIZE = 65_536
16
+ # What browsers read as a comment, and Herb leaves in text, like Word's `<![if !supportLists]>`.
17
+ BOGUS_COMMENT = /<![^>]*>/
18
+
19
+ def self.load_herb
20
+ require "herb"
21
+ rescue LoadError
22
+ raise Error, 'HTML import needs the herb gem: add `gem "herb"` to your Gemfile'
23
+ end
24
+
25
+ # A root Element holding the parsed content.
26
+ def call(html)
27
+ self.class.load_herb
28
+ root = Element.new("#document")
29
+ Splitter.new(PIECE_SIZE).call(html.to_s).each do |piece|
30
+ # Without `timeout: 0`, Herb stops after a second and returns what it parsed so far.
31
+ build(Herb.parse(piece, track_locations: false, strict: false, timeout: 0).value.children, root)
32
+ end
33
+ root
34
+ end
35
+
36
+ private
37
+
38
+ # Each item of the stack is a list of Herb nodes, the position in it, the element to add them to,
39
+ # and its depth.
40
+ def build(nodes, root)
41
+ stack = [[nodes, 0, root, 0]]
42
+ until stack.empty?
43
+ frame = stack.last
44
+ list, index, target, depth = frame
45
+ if index >= list.size
46
+ stack.pop
47
+ next
48
+ end
49
+ frame[1] += 1
50
+ child = add(list[index], target, depth)
51
+ next unless child
52
+
53
+ element, children = child
54
+ if element.equal?(target)
55
+ stack << [children, 0, target, depth]
56
+ elsif children.nil? # an opening tag without a closing tag: the rest of the list goes in it
57
+ frame[2] = element
58
+ frame[3] = depth + 1
59
+ else
60
+ stack << [children, 0, element, depth + 1]
61
+ end
62
+ end
63
+ end
64
+
65
+ # Adds a Herb node to the target. Returns the element to fill and the Herb nodes to fill it with,
66
+ # when the node has children.
67
+ def add(node, target, depth)
68
+ case node
69
+ when Herb::AST::HTMLElementNode then add_element(node, target, depth)
70
+ when Herb::AST::HTMLOpenTagNode then add_open_tag(node, target, depth)
71
+ when Herb::AST::HTMLTextNode then add_text(Entities.decode(node.content.to_s.gsub(BOGUS_COMMENT, "")), target)
72
+ when Herb::AST::LiteralNode then add_text(node.content.to_s, target)
73
+ when Herb::AST::WhitespaceNode then add_text(node.value&.value.to_s, target)
74
+ when Herb::AST::HTMLCommentNode, Herb::AST::HTMLDoctypeNode, Herb::AST::XMLDeclarationNode,
75
+ Herb::AST::XMLProcessingInstructionNode, Herb::AST::CDATANode, Herb::AST::HTMLCloseTagNode
76
+ nil
77
+ else add_other(node, target)
78
+ end
79
+ end
80
+
81
+ def add_element(node, target, depth)
82
+ open_tag = node.open_tag
83
+ return [target, node.body] unless open_tag.respond_to?(:tag_name) && open_tag.tag_name
84
+
85
+ element = new_element(open_tag)
86
+ return [target, node.body] if depth >= MAX_DEPTH
87
+
88
+ target.append(element)
89
+ [element, node.body]
90
+ end
91
+
92
+ def add_open_tag(node, target, depth)
93
+ element = new_element(node)
94
+ return nil if depth >= MAX_DEPTH
95
+
96
+ target.append(element)
97
+ node.is_void ? nil : [element, nil]
98
+ end
99
+
100
+ # ERB tags become text. Other nodes give their children, if any.
101
+ def add_other(node, target)
102
+ if node.respond_to?(:tag_opening) && node.respond_to?(:content) && node.respond_to?(:tag_closing)
103
+ add_text([node.tag_opening, node.content, node.tag_closing].map { |token| token&.value.to_s }.join, target)
104
+ end
105
+ children = node.respond_to?(:child_nodes) ? node.child_nodes.compact : []
106
+ children.empty? ? nil : [target, children]
107
+ end
108
+
109
+ def add_text(value, target)
110
+ return nil if value.empty?
111
+
112
+ last = target.children.last
113
+ if last.is_a?(Text)
114
+ target.children[-1] = Text.new(last.value + value).tap { |text| text.parent = target }
115
+ else
116
+ target.append(Text.new(value))
117
+ end
118
+ nil
119
+ end
120
+
121
+ def new_element(open_tag)
122
+ Element.new(open_tag.tag_name.value.downcase, attributes(open_tag.children))
123
+ end
124
+
125
+ # The first value wins when an attribute is repeated, as in browsers.
126
+ def attributes(nodes)
127
+ nodes.grep(Herb::AST::HTMLAttributeNode).each_with_object({}) do |attribute, result|
128
+ name = text_of(attribute.name&.children).downcase
129
+ next if name.empty? || result.key?(name)
130
+
131
+ result[name] = Entities.decode(text_of(attribute.value&.children), attribute: true)
132
+ end
133
+ end
134
+
135
+ def text_of(nodes)
136
+ Array(nodes).map do |node|
137
+ if node.respond_to?(:tag_opening) && node.respond_to?(:tag_closing)
138
+ [node.tag_opening, node.content, node.tag_closing].map { |token| token&.value.to_s }.join
139
+ else
140
+ node.content.to_s
141
+ end
142
+ end.join
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module HTML
5
+ # An element, with a lowercase name, attributes with lowercase names and decoded values, and
6
+ # children. The import works on these rather than on the parser's own tree.
7
+ class Element
8
+ attr_reader :name, :attributes, :children
9
+ attr_accessor :parent
10
+
11
+ def initialize(name, attributes = {})
12
+ @name = name
13
+ @attributes = attributes
14
+ @children = []
15
+ end
16
+
17
+ def append(child)
18
+ child.parent = self
19
+ @children << child
20
+ child
21
+ end
22
+
23
+ def element?
24
+ true
25
+ end
26
+
27
+ def [](attribute)
28
+ @attributes[attribute]
29
+ end
30
+
31
+ def key?(attribute)
32
+ @attributes.key?(attribute)
33
+ end
34
+
35
+ def classes
36
+ self["class"].to_s.split
37
+ end
38
+
39
+ # The declarations of the `style` attribute, with lowercase properties: `{"font-weight" => "bold"}`.
40
+ def style
41
+ @style ||= self["style"].to_s.split(";").each_with_object({}) do |declaration, styles|
42
+ property, value = declaration.split(":", 2)
43
+ next if value.nil? || property.strip.empty?
44
+
45
+ styles[property.strip.downcase] = value.strip.sub(/\s*!important\z/i, "")
46
+ end
47
+ end
48
+
49
+ def text_content
50
+ @children.map(&:text_content).join
51
+ end
52
+
53
+ def element_children
54
+ @children.select(&:element?)
55
+ end
56
+
57
+ def first_element_child
58
+ @children.find(&:element?)
59
+ end
60
+
61
+ def previous_sibling(child)
62
+ index = @children.index { |candidate| candidate.equal?(child) }
63
+ @children[index - 1] if index&.positive?
64
+ end
65
+
66
+ def matches?(selector)
67
+ Selector.parse(selector).match?(self)
68
+ end
69
+
70
+ # The first descendant matching the selector, depth first.
71
+ def query(selector)
72
+ compiled = Selector.parse(selector)
73
+ stack = element_children.reverse
74
+ until stack.empty?
75
+ element = stack.pop
76
+ return element if compiled.match?(element)
77
+
78
+ stack.concat(element.element_children.reverse)
79
+ end
80
+ nil
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module HTML
5
+ # Decodes character references (`&amp;`, `&eacute;`, `&#233;`, `&#xE9;`) as HTML5 does, with the
6
+ # WHATWG list of named references: https://html.spec.whatwg.org/multipage/named-characters.html
7
+ module Entities
8
+ # Code points 0x80 to 0x9F, read as Windows-1252, as browsers do.
9
+ WINDOWS_1252 = {
10
+ 0x80 => 0x20AC, 0x82 => 0x201A, 0x83 => 0x0192, 0x84 => 0x201E, 0x85 => 0x2026, 0x86 => 0x2020,
11
+ 0x87 => 0x2021, 0x88 => 0x02C6, 0x89 => 0x2030, 0x8A => 0x0160, 0x8B => 0x2039, 0x8C => 0x0152,
12
+ 0x8E => 0x017D, 0x91 => 0x2018, 0x92 => 0x2019, 0x93 => 0x201C, 0x94 => 0x201D, 0x95 => 0x2022,
13
+ 0x96 => 0x2013, 0x97 => 0x2014, 0x98 => 0x02DC, 0x99 => 0x2122, 0x9A => 0x0161, 0x9B => 0x203A,
14
+ 0x9C => 0x0153, 0x9E => 0x017E, 0x9F => 0x0178
15
+ }.freeze
16
+
17
+ # The length of the longest reference without `;`, like `middot`.
18
+ LONGEST_LEGACY_NAME = 6
19
+ REFERENCE = /&(?:#(\d+);?|#[xX](\h+);?|([A-Za-z][A-Za-z0-9]*;?))/
20
+
21
+ module_function
22
+
23
+ # In an attribute, a named reference without a semicolon followed by a letter, a digit or `=`
24
+ # stays as it is, as in browsers (`?a=1&copy=2` keeps `&copy`).
25
+ def decode(string, attribute: false)
26
+ return string unless string.include?("&")
27
+
28
+ string.gsub(REFERENCE) do
29
+ if (decimal = Regexp.last_match(1))
30
+ character(decimal.to_i)
31
+ elsif (hexadecimal = Regexp.last_match(2))
32
+ character(hexadecimal.to_i(16))
33
+ else
34
+ named(Regexp.last_match(3), Regexp.last_match.post_match, attribute) || Regexp.last_match(0)
35
+ end
36
+ end
37
+ end
38
+
39
+ # The characters of a named reference ending with `;`, or nil. `entities.txt` has one reference per
40
+ # line, with its code points in hexadecimal (`nvlt; 3C,20D2`). It is kept as one string, searched with
41
+ # `index`, which takes a tenth of the memory of a Hash. The references found are cached, but not the
42
+ # unknown names, which documents could make up without limit.
43
+ def lookup(name)
44
+ @found ||= {}
45
+ @found[name] ||= begin
46
+ start = list.index("\n#{name} ") or return nil
47
+ characters(list[start + name.length + 2...list.index("\n", start + 1)])
48
+ end
49
+ end
50
+
51
+ # The references without `;`, which browsers also read at the start of a longer name (`&copy2`).
52
+ def legacy
53
+ @legacy ||= list.scan(/^([A-Za-z0-9]+) ([\h,]+)$/).to_h { |name, codes| [name, characters(codes)] }.freeze
54
+ end
55
+
56
+ def list
57
+ @list ||= "\n#{File.read(File.join(__dir__, "entities.txt"))}".freeze
58
+ end
59
+
60
+ def characters(codes)
61
+ codes.split(",").map { |code| code.to_i(16) }.pack("U*").freeze
62
+ end
63
+
64
+ def character(code)
65
+ return "�" if code.zero? || code > 0x10FFFF || (0xD800..0xDFFF).cover?(code)
66
+
67
+ [WINDOWS_1252.fetch(code, code)].pack("U")
68
+ end
69
+
70
+ # The longest reference starting the name, followed by the rest of the name. A `;` can only end the
71
+ # name, so a shorter reference is one without `;`.
72
+ def named(name, following, attribute)
73
+ return lookup(name) if name.end_with?(";") && lookup(name)
74
+
75
+ [name.length, LONGEST_LEGACY_NAME].min.downto(1) do |length|
76
+ next unless (characters = legacy[name[0, length]])
77
+
78
+ rest = name[length..]
79
+ return nil if attribute && "#{rest}#{following}".match?(/\A[A-Za-z0-9=]/)
80
+
81
+ return characters + rest
82
+ end
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end