prosemirror 0.1.0 → 0.3.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +69 -46
  4. data/ROADMAP.md +14 -10
  5. data/TYPES.md +218 -0
  6. data/lib/prosemirror/content_match.rb +241 -0
  7. data/lib/prosemirror/html/builder.rb +154 -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 +503 -0
  15. data/lib/prosemirror/json_parser.rb +1 -12
  16. data/lib/prosemirror/mark_type.rb +11 -0
  17. data/lib/prosemirror/markdown/builder.rb +200 -0
  18. data/lib/prosemirror/marks/basic/code.rb +16 -0
  19. data/lib/prosemirror/marks/basic/em.rb +4 -0
  20. data/lib/prosemirror/marks/basic/link.rb +1 -0
  21. data/lib/prosemirror/marks/basic/strong.rb +4 -0
  22. data/lib/prosemirror/marks/bold.rb +8 -0
  23. data/lib/prosemirror/marks/code.rb +5 -0
  24. data/lib/prosemirror/marks/highlight.rb +3 -1
  25. data/lib/prosemirror/marks/italic.rb +4 -0
  26. data/lib/prosemirror/marks/link.rb +1 -0
  27. data/lib/prosemirror/marks/strike.rb +5 -0
  28. data/lib/prosemirror/marks/subscript.rb +2 -0
  29. data/lib/prosemirror/marks/superscript.rb +2 -0
  30. data/lib/prosemirror/marks/underline.rb +3 -0
  31. data/lib/prosemirror/node.rb +12 -0
  32. data/lib/prosemirror/node_type.rb +22 -0
  33. data/lib/prosemirror/nodes/basic/code_block.rb +1 -0
  34. data/lib/prosemirror/nodes/basic/image.rb +1 -0
  35. data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -1
  36. data/lib/prosemirror/nodes/blockquote.rb +1 -0
  37. data/lib/prosemirror/nodes/bullet_list.rb +1 -0
  38. data/lib/prosemirror/nodes/code_block.rb +6 -1
  39. data/lib/prosemirror/nodes/hard_break.rb +2 -0
  40. data/lib/prosemirror/nodes/heading.rb +1 -0
  41. data/lib/prosemirror/nodes/horizontal_rule.rb +1 -0
  42. data/lib/prosemirror/nodes/image.rb +1 -0
  43. data/lib/prosemirror/nodes/list_item.rb +1 -0
  44. data/lib/prosemirror/nodes/ordered_list.rb +20 -2
  45. data/lib/prosemirror/nodes/paragraph.rb +1 -0
  46. data/lib/prosemirror/nodes/table.rb +1 -0
  47. data/lib/prosemirror/nodes/table_cell.rb +12 -2
  48. data/lib/prosemirror/nodes/table_header.rb +2 -0
  49. data/lib/prosemirror/nodes/table_row.rb +1 -0
  50. data/lib/prosemirror/parse_rule.rb +50 -0
  51. data/lib/prosemirror/schema.rb +87 -15
  52. data/lib/prosemirror/type_definition.rb +34 -4
  53. data/lib/prosemirror/utf8.rb +16 -0
  54. data/lib/prosemirror/version.rb +1 -1
  55. data/lib/prosemirror.rb +22 -1
  56. metadata +15 -1
@@ -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,72 @@ 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
+
120
+ # Build a document from Markdown, with the parse rules of the schema's types. Needs the `commonmarker` gem.
121
+ def parse_markdown(markdown)
122
+ Document.new(HTMLParser.new(self).parse_element(Markdown::Builder.new.call(markdown)), schema: self)
123
+ end
124
+
125
+ # Build a document from plain text: blank lines separate paragraphs, and other line breaks become the
126
+ # schema's line break node, like `hard_break`, or spaces without one. Spaces are kept.
127
+ def parse_text(text)
128
+ paragraph = node_type(:paragraph) || @nodes.find { |type| textblock?(type) }
129
+ linebreak = @nodes.find(&:linebreak_replacement?)
130
+ separator = linebreak ? {"type" => linebreak.type_name.to_s} : {"type" => "text", "text" => " "}
131
+ blocks = UTF8.string(text).gsub(/\r\n?/, "\n").split(/\n[ \t]*\n/).map { |block| block.gsub(/\A\n+|\n+\z/, "") }
132
+ content = blocks.reject { |block| block.strip.empty? }.map do |block|
133
+ inline = block.split("\n").each_with_index.flat_map do |line, index|
134
+ [(separator if index.positive?), ({"type" => "text", "text" => line} unless line.empty?)].compact
135
+ end
136
+ {"type" => paragraph.type_name.to_s, "content" => inline}
137
+ end
138
+ parse({"type" => @top_node.to_s, "content" => content.empty? ? [{"type" => paragraph.type_name.to_s}] : content})
139
+ end
140
+
91
141
  # The type name as written in JSON.
92
142
  def json_type_name(name)
93
143
  (@case == :camel) ? Naming.camel(name) : name.to_s
94
144
  end
95
145
 
146
+ # The set of marks with this mark added, in schema order, as ProseMirror's `Mark#addToSet`: a mark
147
+ # replaces the marks it excludes, and isn't added when a mark of the set excludes it.
148
+ def add_mark(mark, set)
149
+ type = mark_type(mark.type)
150
+ result = []
151
+ placed = false
152
+ set.each do |other|
153
+ return set if other == mark
154
+
155
+ other_type = mark_type(other.type)
156
+ next if type.excludes?(other_type)
157
+ return set if other_type.excludes?(type)
158
+
159
+ if !placed && mark_rank(other.type) > mark_rank(mark.type)
160
+ result << mark
161
+ placed = true
162
+ end
163
+ result << other
164
+ end
165
+ result << mark unless placed
166
+ result
167
+ end
168
+
169
+ # Whether a URL's scheme is allowed, or the URL is relative. Browsers ignore leading control characters
170
+ # and spaces, and tabs and newlines anywhere in a URL.
171
+ def allowed_url?(url)
172
+ return false unless url.is_a?(String)
173
+
174
+ normalized = url.delete("\t\n\r").sub(/\A[\x00-\x20]+/, "")
175
+ scheme = normalized[%r{\A([^/?#]*):}, 1]
176
+ return true if scheme.nil?
177
+
178
+ scheme.match?(/\A[a-z][a-z\d+.-]*\z/i) && @url_schemes.include?(scheme.downcase)
179
+ end
180
+
96
181
  # The order of marks in a node. Marks of unknown types come last.
97
182
  def mark_rank(name)
98
183
  @marks.index(mark_type(name)) || @marks.size
@@ -158,19 +243,6 @@ module Prosemirror
158
243
  end
159
244
  end
160
245
 
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
246
  # Declared attributes with their JSON names, then the unknown ones, as they were.
175
247
  def serialize_node_attrs(node)
176
248
  type = node_type(node.type)
@@ -18,14 +18,34 @@ module Prosemirror
18
18
  inherited_setting(:@aliases, [])
19
19
  end
20
20
 
21
- # Declare an attribute. Same as Literal's `prop`, with two options:
21
+ # Declare an attribute. Same as Literal's `prop`, with these options:
22
22
  # - `json:` the attribute name in JSON, when it differs from the property name (`"textAlign"`);
23
- # - `aliases:` other JSON names accepted on input (`["order"]`).
24
- def prop(name, type, kind = :keyword, json: nil, aliases: [], **, &)
25
- attribute_names[name] = {json: (json || name).to_s, aliases: aliases.map(&:to_s)}.freeze
23
+ # - `aliases:` other JSON names accepted on input (`["order"]`);
24
+ # - `parse_html:` a block reading the attribute from an HTML element, when importing HTML. By default,
25
+ # the HTML attribute with the JSON name is read.
26
+ def prop(name, type, kind = :keyword, json: nil, aliases: [], parse_html: nil, **, &)
27
+ attribute_names[name] = {json: (json || name).to_s, aliases: aliases.map(&:to_s), parse_html:}.freeze
26
28
  super(name, type, kind, **, &)
27
29
  end
28
30
 
31
+ # Declare how to recognize this type in HTML. See ParseRule for the options. The block, if given, is the
32
+ # rule's `get_attrs`. A subclass that declares rules replaces the rules of its parent.
33
+ def parse_html(tag: nil, style: nil, **options, &get_attrs)
34
+ (@parse_rules ||= []) << ParseRule.new(type: self, tag:, style:, get_attrs:, **options)
35
+ end
36
+
37
+ def parse_rules
38
+ inherited_setting(:@parse_rules, [])
39
+ end
40
+
41
+ # The attributes read from an HTML element, by JSON name, without the missing ones.
42
+ def attrs_from_html(element)
43
+ attribute_names.each_value.with_object({}) do |names, attrs|
44
+ value = names[:parse_html] ? names[:parse_html].call(element) : element[names[:json]]
45
+ attrs[names[:json]] = value unless value.nil?
46
+ end
47
+ end
48
+
29
49
  def attribute_names
30
50
  @attribute_names ||= concrete_superclass ? concrete_superclass.attribute_names.dup : {}
31
51
  end
@@ -54,6 +74,16 @@ module Prosemirror
54
74
  end
55
75
  end
56
76
 
77
+ # Whether an attribute has no default, so that a node of this type can't be created empty.
78
+ def required_attrs?
79
+ literal_properties.any? { |property| !optional_attribute?(property) }
80
+ end
81
+
82
+ # The attributes of a node of this type created empty: the defaults.
83
+ def default_attrs
84
+ build_attrs({}, report: ->(_) {})
85
+ end
86
+
57
87
  # The JSON attributes this type doesn't declare, with String keys, so that `to_h` gives them back.
58
88
  def unknown_attrs(given)
59
89
  return {} unless given.is_a?(Hash)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Imported HTML, Markdown and text, as valid UTF-8.
5
+ module UTF8
6
+ module_function
7
+
8
+ # The value as a UTF-8 String: nil becomes empty, other encodings are read as UTF-8 (a binary string
9
+ # from a file or an HTTP body), and invalid bytes become U+FFFD, as browsers do.
10
+ def string(value)
11
+ string = value.to_s
12
+ string = string.dup.force_encoding(Encoding::UTF_8) unless string.encoding == Encoding::UTF_8
13
+ string.valid_encoding? ? string : string.scrub
14
+ end
15
+ end
16
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prosemirror
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/prosemirror.rb CHANGED
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Phlex 2.4.1 checks attribute values against Date without loading it. Remove once a release includes the fix:
4
+ # https://github.com/yippee-fun/phlex/issues/974
5
+ require "date"
3
6
  require "json"
4
7
  require "literal"
5
8
  require "phlex"
6
9
  require "zeitwerk"
7
10
 
8
11
  loader = Zeitwerk::Loader.for_gem
9
- loader.inflector.inflect("html_renderer" => "HTMLRenderer", "json_parser" => "JSONParser")
12
+ loader.inflector.inflect(
13
+ "html" => "HTML", "html_parser" => "HTMLParser", "html_renderer" => "HTMLRenderer", "json_parser" => "JSONParser",
14
+ "utf8" => "UTF8"
15
+ )
10
16
  loader.setup
11
17
 
12
18
  # Render ProseMirror and Tiptap documents.
@@ -35,5 +41,20 @@ module Prosemirror
35
41
  def parse(json, schema: self.schema)
36
42
  schema.parse(json)
37
43
  end
44
+
45
+ # Build a document from HTML, with the schema's parse rules. Needs the `herb` gem.
46
+ def from_html(html, schema: self.schema)
47
+ schema.parse_html(html)
48
+ end
49
+
50
+ # Build a document from Markdown, with the schema's parse rules. Needs the `commonmarker` gem.
51
+ def from_markdown(markdown, schema: self.schema)
52
+ schema.parse_markdown(markdown)
53
+ end
54
+
55
+ # Build a document from plain text: blank lines separate paragraphs, other line breaks become hard breaks.
56
+ def from_text(text, schema: self.schema)
57
+ schema.parse_text(text)
58
+ end
38
59
  end
39
60
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prosemirror
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans Lemuet
@@ -64,14 +64,26 @@ files:
64
64
  - LICENSE.txt
65
65
  - README.md
66
66
  - ROADMAP.md
67
+ - TYPES.md
67
68
  - lib/prosemirror.rb
68
69
  - lib/prosemirror/coerce.rb
69
70
  - lib/prosemirror/content_expression.rb
71
+ - lib/prosemirror/content_match.rb
70
72
  - lib/prosemirror/document.rb
73
+ - lib/prosemirror/html/builder.rb
74
+ - lib/prosemirror/html/element.rb
75
+ - lib/prosemirror/html/entities.rb
76
+ - lib/prosemirror/html/entities.txt
77
+ - lib/prosemirror/html/selector.rb
78
+ - lib/prosemirror/html/splitter.rb
79
+ - lib/prosemirror/html/text.rb
80
+ - lib/prosemirror/html_parser.rb
71
81
  - lib/prosemirror/html_renderer.rb
72
82
  - lib/prosemirror/json_parser.rb
73
83
  - lib/prosemirror/mark.rb
74
84
  - lib/prosemirror/mark_type.rb
85
+ - lib/prosemirror/markdown/builder.rb
86
+ - lib/prosemirror/marks/basic/code.rb
75
87
  - lib/prosemirror/marks/basic/em.rb
76
88
  - lib/prosemirror/marks/basic/link.rb
77
89
  - lib/prosemirror/marks/basic/strong.rb
@@ -106,10 +118,12 @@ files:
106
118
  - lib/prosemirror/nodes/table_header.rb
107
119
  - lib/prosemirror/nodes/table_row.rb
108
120
  - lib/prosemirror/nodes/text.rb
121
+ - lib/prosemirror/parse_rule.rb
109
122
  - lib/prosemirror/rendering.rb
110
123
  - lib/prosemirror/schema.rb
111
124
  - lib/prosemirror/text_serializer.rb
112
125
  - lib/prosemirror/type_definition.rb
126
+ - lib/prosemirror/utf8.rb
113
127
  - lib/prosemirror/version.rb
114
128
  - sig/prosemirror.rbs
115
129
  homepage: https://github.com/etaminstudio/prosemirror