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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/COOKBOOK.md +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +169 -12
- data/ROADMAP.md +211 -0
- data/lib/prosemirror/coerce.rb +17 -0
- data/lib/prosemirror/content_expression.rb +94 -0
- data/lib/prosemirror/document.rb +76 -0
- data/lib/prosemirror/html_renderer.rb +20 -0
- data/lib/prosemirror/json_parser.rb +125 -0
- data/lib/prosemirror/mark.rb +19 -0
- data/lib/prosemirror/mark_type.rb +60 -0
- data/lib/prosemirror/marks/basic/em.rb +13 -0
- data/lib/prosemirror/marks/basic/link.rb +20 -0
- data/lib/prosemirror/marks/basic/strong.rb +13 -0
- data/lib/prosemirror/marks/bold.rb +11 -0
- data/lib/prosemirror/marks/code.rb +11 -0
- data/lib/prosemirror/marks/highlight.rb +16 -0
- data/lib/prosemirror/marks/italic.rb +11 -0
- data/lib/prosemirror/marks/link.rb +21 -0
- data/lib/prosemirror/marks/strike.rb +9 -0
- data/lib/prosemirror/marks/subscript.rb +11 -0
- data/lib/prosemirror/marks/superscript.rb +11 -0
- data/lib/prosemirror/marks/underline.rb +9 -0
- data/lib/prosemirror/naming.rb +16 -0
- data/lib/prosemirror/node.rb +65 -0
- data/lib/prosemirror/node_type.rb +101 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +19 -0
- data/lib/prosemirror/nodes/basic/image.rb +22 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +19 -0
- data/lib/prosemirror/nodes/blockquote.rb +12 -0
- data/lib/prosemirror/nodes/bullet_list.rb +12 -0
- data/lib/prosemirror/nodes/code_block.rb +19 -0
- data/lib/prosemirror/nodes/doc.rb +10 -0
- data/lib/prosemirror/nodes/hard_break.rb +13 -0
- data/lib/prosemirror/nodes/heading.rb +14 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +11 -0
- data/lib/prosemirror/nodes/image.rb +21 -0
- data/lib/prosemirror/nodes/list_item.rb +11 -0
- data/lib/prosemirror/nodes/ordered_list.rb +18 -0
- data/lib/prosemirror/nodes/paragraph.rb +12 -0
- data/lib/prosemirror/nodes/table.rb +14 -0
- data/lib/prosemirror/nodes/table_cell.rb +23 -0
- data/lib/prosemirror/nodes/table_header.rb +13 -0
- data/lib/prosemirror/nodes/table_row.rb +11 -0
- data/lib/prosemirror/nodes/text.rb +10 -0
- data/lib/prosemirror/rendering.rb +70 -0
- data/lib/prosemirror/schema.rb +190 -0
- data/lib/prosemirror/text_serializer.rb +46 -0
- data/lib/prosemirror/type_definition.rb +119 -0
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +33 -2
- metadata +101 -16
- data/.rubocop.yml +0 -13
- data/Gemfile +0 -12
- data/Rakefile +0 -16
- 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,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,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,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,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,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,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("&", "&")
|
|
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
|