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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/COOKBOOK.md +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +155 -12
- data/ROADMAP.md +215 -0
- data/TYPES.md +201 -0
- data/lib/prosemirror/coerce.rb +17 -0
- data/lib/prosemirror/content_expression.rb +94 -0
- data/lib/prosemirror/content_match.rb +241 -0
- data/lib/prosemirror/document.rb +76 -0
- data/lib/prosemirror/html/builder.rb +146 -0
- data/lib/prosemirror/html/element.rb +84 -0
- data/lib/prosemirror/html/entities.rb +87 -0
- data/lib/prosemirror/html/entities.txt +2231 -0
- data/lib/prosemirror/html/selector.rb +95 -0
- data/lib/prosemirror/html/splitter.rb +68 -0
- data/lib/prosemirror/html/text.rb +23 -0
- data/lib/prosemirror/html_parser.rb +498 -0
- data/lib/prosemirror/html_renderer.rb +20 -0
- data/lib/prosemirror/json_parser.rb +114 -0
- data/lib/prosemirror/mark.rb +19 -0
- data/lib/prosemirror/mark_type.rb +71 -0
- data/lib/prosemirror/marks/basic/code.rb +16 -0
- data/lib/prosemirror/marks/basic/em.rb +17 -0
- data/lib/prosemirror/marks/basic/link.rb +21 -0
- data/lib/prosemirror/marks/basic/strong.rb +17 -0
- data/lib/prosemirror/marks/bold.rb +19 -0
- data/lib/prosemirror/marks/code.rb +16 -0
- data/lib/prosemirror/marks/highlight.rb +18 -0
- data/lib/prosemirror/marks/italic.rb +15 -0
- data/lib/prosemirror/marks/link.rb +22 -0
- data/lib/prosemirror/marks/strike.rb +14 -0
- data/lib/prosemirror/marks/subscript.rb +13 -0
- data/lib/prosemirror/marks/superscript.rb +13 -0
- data/lib/prosemirror/marks/underline.rb +12 -0
- data/lib/prosemirror/naming.rb +16 -0
- data/lib/prosemirror/node.rb +77 -0
- data/lib/prosemirror/node_type.rb +123 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +20 -0
- data/lib/prosemirror/nodes/basic/image.rb +23 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +22 -0
- data/lib/prosemirror/nodes/blockquote.rb +13 -0
- data/lib/prosemirror/nodes/bullet_list.rb +13 -0
- data/lib/prosemirror/nodes/code_block.rb +24 -0
- data/lib/prosemirror/nodes/doc.rb +10 -0
- data/lib/prosemirror/nodes/hard_break.rb +15 -0
- data/lib/prosemirror/nodes/heading.rb +15 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +12 -0
- data/lib/prosemirror/nodes/image.rb +22 -0
- data/lib/prosemirror/nodes/list_item.rb +12 -0
- data/lib/prosemirror/nodes/ordered_list.rb +36 -0
- data/lib/prosemirror/nodes/paragraph.rb +13 -0
- data/lib/prosemirror/nodes/table.rb +15 -0
- data/lib/prosemirror/nodes/table_cell.rb +33 -0
- data/lib/prosemirror/nodes/table_header.rb +15 -0
- data/lib/prosemirror/nodes/table_row.rb +12 -0
- data/lib/prosemirror/nodes/text.rb +10 -0
- data/lib/prosemirror/parse_rule.rb +50 -0
- data/lib/prosemirror/rendering.rb +70 -0
- data/lib/prosemirror/schema.rb +241 -0
- data/lib/prosemirror/text_serializer.rb +46 -0
- data/lib/prosemirror/type_definition.rb +149 -0
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +43 -2
- metadata +113 -16
- data/.rubocop.yml +0 -13
- data/Gemfile +0 -12
- data/Rakefile +0 -16
- 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 (`&`, `é`, `é`, `é`) 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©=2` keeps `©`).
|
|
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 (`©2`).
|
|
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
|