prosemirror 0.0.1 → 0.1.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/COOKBOOK.md +108 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +169 -12
  6. data/ROADMAP.md +211 -0
  7. data/lib/prosemirror/coerce.rb +17 -0
  8. data/lib/prosemirror/content_expression.rb +94 -0
  9. data/lib/prosemirror/document.rb +76 -0
  10. data/lib/prosemirror/html_renderer.rb +20 -0
  11. data/lib/prosemirror/json_parser.rb +125 -0
  12. data/lib/prosemirror/mark.rb +19 -0
  13. data/lib/prosemirror/mark_type.rb +60 -0
  14. data/lib/prosemirror/marks/basic/em.rb +13 -0
  15. data/lib/prosemirror/marks/basic/link.rb +20 -0
  16. data/lib/prosemirror/marks/basic/strong.rb +13 -0
  17. data/lib/prosemirror/marks/bold.rb +11 -0
  18. data/lib/prosemirror/marks/code.rb +11 -0
  19. data/lib/prosemirror/marks/highlight.rb +16 -0
  20. data/lib/prosemirror/marks/italic.rb +11 -0
  21. data/lib/prosemirror/marks/link.rb +21 -0
  22. data/lib/prosemirror/marks/strike.rb +9 -0
  23. data/lib/prosemirror/marks/subscript.rb +11 -0
  24. data/lib/prosemirror/marks/superscript.rb +11 -0
  25. data/lib/prosemirror/marks/underline.rb +9 -0
  26. data/lib/prosemirror/naming.rb +16 -0
  27. data/lib/prosemirror/node.rb +65 -0
  28. data/lib/prosemirror/node_type.rb +101 -0
  29. data/lib/prosemirror/nodes/basic/code_block.rb +19 -0
  30. data/lib/prosemirror/nodes/basic/image.rb +22 -0
  31. data/lib/prosemirror/nodes/basic/ordered_list.rb +19 -0
  32. data/lib/prosemirror/nodes/blockquote.rb +12 -0
  33. data/lib/prosemirror/nodes/bullet_list.rb +12 -0
  34. data/lib/prosemirror/nodes/code_block.rb +19 -0
  35. data/lib/prosemirror/nodes/doc.rb +10 -0
  36. data/lib/prosemirror/nodes/hard_break.rb +13 -0
  37. data/lib/prosemirror/nodes/heading.rb +14 -0
  38. data/lib/prosemirror/nodes/horizontal_rule.rb +11 -0
  39. data/lib/prosemirror/nodes/image.rb +21 -0
  40. data/lib/prosemirror/nodes/list_item.rb +11 -0
  41. data/lib/prosemirror/nodes/ordered_list.rb +18 -0
  42. data/lib/prosemirror/nodes/paragraph.rb +12 -0
  43. data/lib/prosemirror/nodes/table.rb +14 -0
  44. data/lib/prosemirror/nodes/table_cell.rb +23 -0
  45. data/lib/prosemirror/nodes/table_header.rb +13 -0
  46. data/lib/prosemirror/nodes/table_row.rb +11 -0
  47. data/lib/prosemirror/nodes/text.rb +10 -0
  48. data/lib/prosemirror/rendering.rb +70 -0
  49. data/lib/prosemirror/schema.rb +190 -0
  50. data/lib/prosemirror/text_serializer.rb +46 -0
  51. data/lib/prosemirror/type_definition.rb +119 -0
  52. data/lib/prosemirror/version.rb +1 -1
  53. data/lib/prosemirror.rb +33 -2
  54. metadata +101 -16
  55. data/.rubocop.yml +0 -13
  56. data/Gemfile +0 -12
  57. data/Rakefile +0 -16
  58. data/prosemirror.gemspec +0 -35
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # The base class of node types. A node type is a Phlex component: `view_template` renders a node,
5
+ # and yields to render its content. Attributes are Literal properties, available as instance variables.
6
+ #
7
+ # class Heading < Prosemirror::NodeType
8
+ # group :block
9
+ # content "inline*"
10
+ # prop :level, _Integer(1..6), default: 1
11
+ #
12
+ # def view_template(&) = tag(:"h#{@level}", &)
13
+ # end
14
+ #
15
+ # The options follow ProseMirror's NodeSpec: https://prosemirror.net/docs/ref/#model.NodeSpec
16
+ class NodeType < Phlex::HTML
17
+ extend Literal::Properties
18
+ extend TypeDefinition
19
+ include Rendering
20
+
21
+ class << self
22
+ def base_type?
23
+ self == NodeType
24
+ end
25
+
26
+ # The content expression, such as `"block+"`. Nodes without one are leaves.
27
+ def content(expression = nil)
28
+ return @content = ContentExpression.new(expression) if expression
29
+
30
+ inherited_setting(:@content, nil)
31
+ end
32
+
33
+ # The groups this type belongs to, such as `:block` or `:inline`.
34
+ def group(*names)
35
+ return @group = names.flat_map { |n| n.to_s.split }.map(&:to_sym) unless names.empty?
36
+
37
+ inherited_setting(:@group, [])
38
+ end
39
+
40
+ def inline(value = true)
41
+ @inline = value
42
+ end
43
+
44
+ def inline?
45
+ inherited_setting(:@inline, false) || group.include?(:inline)
46
+ end
47
+
48
+ def block?
49
+ !inline?
50
+ end
51
+
52
+ def leaf?
53
+ content.nil?
54
+ end
55
+
56
+ # The marks allowed inside this node: mark or group names, `"_"` for all, `""` for none.
57
+ def marks(spec = nil)
58
+ return @marks = spec if spec
59
+
60
+ inherited_setting(:@marks, nil)
61
+ end
62
+
63
+ # `:pre` to keep whitespace when parsing. Implied by `code`.
64
+ def whitespace(value = nil)
65
+ return @whitespace = value if value
66
+
67
+ inherited_setting(:@whitespace, code? ? :pre : :normal)
68
+ end
69
+
70
+ def code(value = true)
71
+ @code = value
72
+ end
73
+
74
+ def code?
75
+ inherited_setting(:@code, false)
76
+ end
77
+
78
+ # The text of a leaf node in `to_text`, as a String or a block called with the node.
79
+ def leaf_text(text = nil, &block)
80
+ return @leaf_text = text || block if text || block
81
+
82
+ inherited_setting(:@leaf_text, nil)
83
+ end
84
+
85
+ def text_for(node)
86
+ value = leaf_text
87
+ value.respond_to?(:call) ? value.call(node).to_s : value.to_s
88
+ end
89
+ end
90
+
91
+ # The node being rendered.
92
+ def node
93
+ @_prosemirror_node
94
+ end
95
+
96
+ # Render the node's content when no `view_template` is defined.
97
+ def view_template
98
+ yield if block_given?
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ module Basic
6
+ # ProseMirror's code block, without attributes.
7
+ class CodeBlock < NodeType
8
+ group :block
9
+ content "text*"
10
+ marks ""
11
+ code
12
+
13
+ def view_template(&)
14
+ pre { code(&) }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ module Basic
6
+ # ProseMirror's image, an inline node. Without an allowed `src`, it renders nothing.
7
+ class Image < NodeType
8
+ group :inline
9
+ inline
10
+
11
+ prop :src, _String(length: 1..)
12
+ prop :alt, _String?
13
+ prop :title, _String?
14
+
15
+ def view_template
16
+ src = safe_url(@src)
17
+ img(src: src, alt: @alt, title: @title) if src
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ module Basic
6
+ # ProseMirror's ordered list. Tiptap's `start` attribute is read as `order`.
7
+ class OrderedList < NodeType
8
+ group :block, :list
9
+ content "list_item+"
10
+
11
+ prop :order, Integer, default: 1, aliases: ["start"], &Coerce::INTEGER
12
+
13
+ def view_template(&)
14
+ ol(start: (@order unless @order == 1), &)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class Blockquote < NodeType
6
+ group :block
7
+ content "block+"
8
+
9
+ def view_template(&) = blockquote(&)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class BulletList < NodeType
6
+ group :block, :list
7
+ content "list_item+"
8
+
9
+ def view_template(&) = ul(&)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ # Tiptap's code block. `prosemirror-markdown`'s `params` attribute is read as `language`.
6
+ class CodeBlock < NodeType
7
+ group :block
8
+ content "text*"
9
+ marks ""
10
+ code
11
+
12
+ prop :language, _String?(/\A[\w#+.-]+\z/), aliases: ["params"]
13
+
14
+ def view_template(&)
15
+ pre { code(class: @language && "language-#{@language}", &) }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ # The top node of a document. It renders its content only.
6
+ class Doc < NodeType
7
+ content "block+"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class HardBreak < NodeType
6
+ group :inline
7
+ inline
8
+ leaf_text "\n"
9
+
10
+ def view_template = br
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class Heading < NodeType
6
+ group :block
7
+ content "inline*"
8
+
9
+ prop :level, _Integer(1..6), default: 1, &Coerce::INTEGER
10
+
11
+ def view_template(&) = tag(:"h#{@level}", &)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class HorizontalRule < NodeType
6
+ group :block
7
+
8
+ def view_template = hr
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ # Tiptap's image, a block node. Without an allowed `src`, it renders nothing.
6
+ class Image < NodeType
7
+ group :block
8
+
9
+ prop :src, _String(length: 1..)
10
+ prop :alt, _String?
11
+ prop :title, _String?
12
+ prop :width, _Integer?(1..), &Coerce::INTEGER
13
+ prop :height, _Integer?(1..), &Coerce::INTEGER
14
+
15
+ def view_template
16
+ src = safe_url(@src)
17
+ img(src: src, alt: @alt, title: @title, width: @width, height: @height) if src
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class ListItem < NodeType
6
+ content "paragraph block*"
7
+
8
+ def view_template(&) = li(&)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ # Tiptap's ordered list. ProseMirror's `order` attribute is read as `start`.
6
+ class OrderedList < NodeType
7
+ group :block, :list
8
+ content "list_item+"
9
+
10
+ prop :start, Integer, default: 1, aliases: ["order"], &Coerce::INTEGER
11
+ prop :type, _Union?("1", "a", "A", "i", "I")
12
+
13
+ def view_template(&)
14
+ ol(start: (@start unless @start == 1), type: @type, &)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class Paragraph < NodeType
6
+ group :block
7
+ content "inline*"
8
+
9
+ def view_template(&) = p(&)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class Table < NodeType
6
+ group :block
7
+ content "table_row+"
8
+
9
+ def view_template(&)
10
+ table { tbody(&) }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class TableCell < NodeType
6
+ content "block+"
7
+
8
+ prop :colspan, _Integer(1..), default: 1, &Coerce::INTEGER
9
+ prop :rowspan, _Integer(1..), default: 1, &Coerce::INTEGER
10
+ prop :colwidth, _Array?(_Integer(1..))
11
+
12
+ def view_template(&)
13
+ td(**cell_attributes, &)
14
+ end
15
+
16
+ private
17
+
18
+ def cell_attributes
19
+ {colspan: (@colspan unless @colspan == 1), rowspan: (@rowspan unless @rowspan == 1)}
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class TableHeader < TableCell
6
+ type_name :table_header
7
+
8
+ def view_template(&)
9
+ th(**cell_attributes, &)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class TableRow < NodeType
6
+ content "(table_cell | table_header)*"
7
+
8
+ def view_template(&) = tr(&)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ # Text. Text nodes are rendered by the renderer, not by this type.
6
+ class Text < NodeType
7
+ group :inline
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Renders nodes and their marks. Included in the HTML renderer, and in node and mark types
5
+ # so that they can render any part of their content.
6
+ module Rendering
7
+ # Render a list of nodes with their marks. A mark shared by adjacent nodes is rendered once.
8
+ def render_content(nodes)
9
+ render_fragment(nodes, 0)
10
+ end
11
+
12
+ # The schema of the document being rendered.
13
+ def prosemirror_schema
14
+ @_prosemirror_schema
15
+ end
16
+
17
+ # The URL, when its scheme is allowed, after the schema's `resolve_url`. Otherwise nil.
18
+ def safe_url(url)
19
+ prosemirror_schema.safe_url(url)
20
+ end
21
+
22
+ private
23
+
24
+ def render_fragment(nodes, depth)
25
+ index = 0
26
+ while index < nodes.size
27
+ mark = nodes[index].marks[depth]
28
+ unless mark
29
+ render_node(nodes[index])
30
+ index += 1
31
+ next
32
+ end
33
+
34
+ last = last_index_with_mark(nodes, index, depth, mark)
35
+ render_marked(mark, nodes[index..last], depth)
36
+ index = last + 1
37
+ end
38
+ end
39
+
40
+ def last_index_with_mark(nodes, index, depth, mark)
41
+ return index unless prosemirror_schema.mark_type(mark.type)&.spanning?
42
+
43
+ index += 1 while index + 1 < nodes.size && nodes[index + 1].marks[depth] == mark
44
+ index
45
+ end
46
+
47
+ def render_marked(mark, nodes, depth)
48
+ mark_type = prosemirror_schema.mark_type(mark.type)
49
+ return render_fragment(nodes, depth + 1) unless mark_type
50
+
51
+ render(prepare(mark_type.new(**mark.attrs))) { render_fragment(nodes, depth + 1) }
52
+ end
53
+
54
+ def render_node(node)
55
+ return plain(node.text) if node.text?
56
+
57
+ node_type = prosemirror_schema.node_type(node.type)
58
+ return render_content(node.content) unless node_type
59
+
60
+ component = prepare(node_type.new(**node.attrs))
61
+ component.instance_variable_set(:@_prosemirror_node, node)
62
+ render(component) { render_content(node.content) }
63
+ end
64
+
65
+ def prepare(component)
66
+ component.instance_variable_set(:@_prosemirror_schema, prosemirror_schema)
67
+ component
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # A set of node and mark types, with rendering options.
5
+ #
6
+ # schema = Prosemirror::Schema.tiptap(case: :camel).with(ImageBlock)
7
+ # schema.parse(json).to_html
8
+ class Schema < Literal::Data
9
+ DEFAULT_URL_SCHEMES = %w[http https mailto tel].freeze
10
+
11
+ prop :nodes, _Array(_Class(NodeType)), &Coerce::FROZEN
12
+ prop :marks, _Array(_Class(MarkType)), &Coerce::FROZEN
13
+ prop :top_node, Symbol, default: :doc, &Coerce::SYMBOL
14
+ # `:snake` or `:camel`, for type names in JSON output.
15
+ prop :case, _Union(:snake, :camel), default: :snake, reader: false
16
+ # The URL schemes allowed in links and images. Relative URLs are always allowed.
17
+ prop :url_schemes, _Array(String), default: DEFAULT_URL_SCHEMES do |schemes|
18
+ schemes.map(&:downcase).freeze
19
+ end
20
+ # Called with each URL before it is checked, to rewrite it.
21
+ prop :resolve_url, _Callable?, reader: false
22
+ # Raise `InvalidDocument` instead of fixing invalid documents.
23
+ prop :strict, _Boolean, default: false, reader: false, predicate: :public
24
+ # Called with an `InvalidDocument` error for each problem fixed.
25
+ prop :on_invalid, _Callable?, reader: false
26
+
27
+ # Tiptap's StarterKit and common extensions.
28
+ def self.tiptap(**)
29
+ new(
30
+ nodes: [
31
+ Nodes::Doc, Nodes::Paragraph, Nodes::Text, Nodes::Heading, Nodes::Blockquote, Nodes::BulletList,
32
+ Nodes::OrderedList, Nodes::ListItem, Nodes::CodeBlock, Nodes::HardBreak, Nodes::HorizontalRule,
33
+ Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell
34
+ ],
35
+ marks: [
36
+ Marks::Link, Marks::Bold, Marks::Italic, Marks::Underline, Marks::Strike, Marks::Code,
37
+ Marks::Subscript, Marks::Superscript, Marks::Highlight
38
+ ],
39
+ **
40
+ )
41
+ end
42
+
43
+ # ProseMirror's prosemirror-schema-basic and prosemirror-schema-list.
44
+ def self.prosemirror(**)
45
+ new(
46
+ nodes: [
47
+ Nodes::Doc, Nodes::Paragraph, Nodes::Blockquote, Nodes::HorizontalRule, Nodes::Heading,
48
+ Nodes::Basic::CodeBlock, Nodes::Text, Nodes::Basic::Image, Nodes::HardBreak, Nodes::Basic::OrderedList,
49
+ Nodes::BulletList, Nodes::ListItem
50
+ ],
51
+ marks: [Marks::Basic::Link, Marks::Basic::Em, Marks::Basic::Strong, Marks::Code],
52
+ **
53
+ )
54
+ end
55
+
56
+ def after_initialize
57
+ @node_lookup = lookup(@nodes)
58
+ @mark_lookup = lookup(@marks)
59
+ @textblocks = @nodes.to_h { |type| [type, textblock_type?(type)] }
60
+ raise ArgumentError, "the schema has no #{@top_node} node type" unless node_type(@top_node)
61
+ end
62
+
63
+ # A copy of the schema with other options.
64
+ def with_options(**)
65
+ self.class.new(**to_h, **)
66
+ end
67
+
68
+ # A copy of the schema with more types. A type replaces the type with the same name.
69
+ def with(*types)
70
+ nodes = types.select { |type| type.is_a?(Class) && type < NodeType }
71
+ marks = types.select { |type| type.is_a?(Class) && type < MarkType }
72
+ raise ArgumentError, "not a node or mark type" if nodes.size + marks.size != types.size
73
+
74
+ self.class.new(**to_h, nodes: merge_types(@nodes, nodes), marks: merge_types(@marks, marks))
75
+ end
76
+
77
+ # The node type for a name, alias, or camelCase name.
78
+ def node_type(name)
79
+ @node_lookup[Naming.snake(name).to_sym]
80
+ end
81
+
82
+ def mark_type(name)
83
+ @mark_lookup[Naming.snake(name).to_sym]
84
+ end
85
+
86
+ # Build a document from JSON, given as a Hash or a String.
87
+ def parse(json)
88
+ Document.new(JSONParser.new(self).parse(json), schema: self)
89
+ end
90
+
91
+ # The type name as written in JSON.
92
+ def json_type_name(name)
93
+ (@case == :camel) ? Naming.camel(name) : name.to_s
94
+ end
95
+
96
+ # The order of marks in a node. Marks of unknown types come last.
97
+ def mark_rank(name)
98
+ @marks.index(mark_type(name)) || @marks.size
99
+ end
100
+
101
+ # Whether nodes of this type contain inline content, like a paragraph.
102
+ def textblock?(type)
103
+ @textblocks.fetch(type, false)
104
+ end
105
+
106
+ # The URL after `resolve_url`, when its scheme is allowed. Otherwise nil, and the problem is reported.
107
+ def safe_url(url)
108
+ return nil unless url.is_a?(String)
109
+
110
+ resolved = @resolve_url ? @resolve_url.call(url) : url
111
+ return report("URL could not be resolved: #{url.inspect}") unless resolved.is_a?(String)
112
+ return report("URL scheme not allowed: #{resolved.inspect}") unless allowed_url?(resolved)
113
+
114
+ resolved.gsub("&", "&amp;")
115
+ end
116
+
117
+ # Raise in strict mode, otherwise call `on_invalid`.
118
+ def report(message, path: nil)
119
+ error = InvalidDocument.new(message, path: path)
120
+ raise error if @strict
121
+
122
+ @on_invalid&.call(error)
123
+ nil
124
+ end
125
+
126
+ # The document as JSON, as a Hash.
127
+ def serialize(node)
128
+ hash = {"type" => json_type_name(node.type)}
129
+ hash["text"] = node.text if node.text?
130
+ attrs = serialize_node_attrs(node)
131
+ hash["attrs"] = attrs unless attrs.empty?
132
+ hash["content"] = node.content.map { |child| serialize(child) } unless node.content.empty?
133
+ hash["marks"] = node.marks.map { |mark| serialize_mark(mark) } unless node.marks.empty?
134
+ hash
135
+ end
136
+
137
+ private
138
+
139
+ def lookup(types)
140
+ types.each_with_object({}) do |type, table|
141
+ [type.type_name, *type.aliases].each { |name| table[name] ||= type }
142
+ table[type.type_name] = type
143
+ end
144
+ end
145
+
146
+ def merge_types(types, additions)
147
+ additions.reduce(types) do |list, addition|
148
+ index = list.index { |type| type.type_name == addition.type_name }
149
+ index ? list.dup.tap { |copy| copy[index] = addition } : list + [addition]
150
+ end
151
+ end
152
+
153
+ def textblock_type?(type)
154
+ return false if type.leaf?
155
+
156
+ type.content.names.any? do |name|
157
+ name == :text || name == :inline || node_types_named(name).any?(&:inline?)
158
+ end
159
+ end
160
+
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
+ # Declared attributes with their JSON names, then the unknown ones, as they were.
175
+ def serialize_node_attrs(node)
176
+ type = node_type(node.type)
177
+ attrs = type ? type.serialize_attrs(node.attrs) : node.attrs.transform_keys(&:to_s)
178
+ attrs.merge(node.unknown_attrs)
179
+ end
180
+
181
+ def serialize_mark(mark)
182
+ hash = {"type" => json_type_name(mark.type)}
183
+ type = mark_type(mark.type)
184
+ attrs = type ? type.serialize_attrs(mark.attrs) : mark.attrs.transform_keys(&:to_s)
185
+ attrs = attrs.merge(mark.unknown_attrs)
186
+ hash["attrs"] = attrs unless attrs.empty?
187
+ hash
188
+ end
189
+ end
190
+ end