prosemirror 0.4.0 → 0.5.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +18 -0
  4. data/ROADMAP.md +5 -3
  5. data/TYPES.md +43 -0
  6. data/lib/prosemirror/checker.rb +2 -2
  7. data/lib/prosemirror/coerce.rb +7 -0
  8. data/lib/prosemirror/document.rb +6 -0
  9. data/lib/prosemirror/html/element.rb +1 -1
  10. data/lib/prosemirror/html/selector.rb +12 -4
  11. data/lib/prosemirror/html_parser.rb +9 -2
  12. data/lib/prosemirror/json_parser.rb +71 -40
  13. data/lib/prosemirror/mark.rb +8 -3
  14. data/lib/prosemirror/mark_type.rb +15 -0
  15. data/lib/prosemirror/markdown/builder.rb +19 -9
  16. data/lib/prosemirror/markdown_serializer.rb +516 -0
  17. data/lib/prosemirror/marks/basic/code.rb +2 -0
  18. data/lib/prosemirror/marks/basic/em.rb +2 -0
  19. data/lib/prosemirror/marks/basic/link.rb +2 -0
  20. data/lib/prosemirror/marks/basic/strong.rb +2 -0
  21. data/lib/prosemirror/marks/bold.rb +2 -0
  22. data/lib/prosemirror/marks/code.rb +2 -0
  23. data/lib/prosemirror/marks/italic.rb +2 -0
  24. data/lib/prosemirror/marks/link.rb +2 -0
  25. data/lib/prosemirror/marks/strike.rb +2 -0
  26. data/lib/prosemirror/node.rb +21 -6
  27. data/lib/prosemirror/node_type.rb +13 -0
  28. data/lib/prosemirror/nodes/basic/code_block.rb +4 -0
  29. data/lib/prosemirror/nodes/basic/image.rb +4 -0
  30. data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -0
  31. data/lib/prosemirror/nodes/blockquote.rb +4 -0
  32. data/lib/prosemirror/nodes/bullet_list.rb +6 -0
  33. data/lib/prosemirror/nodes/code_block.rb +15 -0
  34. data/lib/prosemirror/nodes/hard_break.rb +5 -0
  35. data/lib/prosemirror/nodes/heading.rb +6 -0
  36. data/lib/prosemirror/nodes/horizontal_rule.rb +5 -0
  37. data/lib/prosemirror/nodes/image.rb +16 -0
  38. data/lib/prosemirror/nodes/list_item.rb +4 -0
  39. data/lib/prosemirror/nodes/ordered_list.rb +11 -0
  40. data/lib/prosemirror/nodes/paragraph.rb +8 -0
  41. data/lib/prosemirror/nodes/table.rb +36 -0
  42. data/lib/prosemirror/nodes/text.rb +4 -0
  43. data/lib/prosemirror/schema.rb +14 -2
  44. data/lib/prosemirror/type_definition.rb +33 -13
  45. data/lib/prosemirror/version.rb +1 -1
  46. metadata +2 -1
@@ -16,6 +16,10 @@ module Prosemirror
16
16
  def view_template(&)
17
17
  ol(start: (@order unless @order == 1), &)
18
18
  end
19
+
20
+ def to_markdown(markdown, parent, index)
21
+ Nodes::OrderedList.numbered(markdown, node, @order, markdown.list_after_list?(parent, index) ? ")" : ".")
22
+ end
19
23
  end
20
24
  end
21
25
  end
@@ -8,6 +8,10 @@ module Prosemirror
8
8
  parse_html tag: "blockquote"
9
9
 
10
10
  def view_template(&) = blockquote(&)
11
+
12
+ def to_markdown(markdown, _parent, _index)
13
+ markdown.wrap_block("> ", nil, node) { markdown.render_content(node) }
14
+ end
11
15
  end
12
16
  end
13
17
  end
@@ -8,6 +8,12 @@ module Prosemirror
8
8
  parse_html tag: "ul"
9
9
 
10
10
  def view_template(&) = ul(&)
11
+
12
+ # `-` items, or `*` right after another bullet list, which would otherwise continue it.
13
+ def to_markdown(markdown, parent, index)
14
+ marker = markdown.list_after_list?(parent, index) ? "* " : "- "
15
+ markdown.render_list(node, " ") { marker }
16
+ end
11
17
  end
12
18
  end
13
19
  end
@@ -19,6 +19,21 @@ module Prosemirror
19
19
  def view_template(&)
20
20
  pre { code(class: @language && "language-#{@language}", &) }
21
21
  end
22
+
23
+ def to_markdown(markdown, _parent, _index)
24
+ self.class.fenced(markdown, node, @language)
25
+ end
26
+
27
+ # A fenced code block, with a fence longer than any run of backticks in the code.
28
+ def self.fenced(markdown, node, language)
29
+ code = node.text_content
30
+ fence = "`" * [3, (code.scan(/`{3,}/).map(&:length).max || 0) + 1].max
31
+ markdown.write("#{fence}#{language}\n")
32
+ markdown.text(code, escape: false)
33
+ markdown.ensure_new_line
34
+ markdown.write(fence)
35
+ markdown.close_block(node)
36
+ end
22
37
  end
23
38
  end
24
39
  end
@@ -10,6 +10,11 @@ module Prosemirror
10
10
  parse_html tag: "br"
11
11
 
12
12
  def view_template = br
13
+
14
+ # A backslash before the newline, unless only line breaks follow.
15
+ def to_markdown(markdown, parent, index)
16
+ markdown.write("\\\n") if parent.content[(index + 1)..].any? { |sibling| sibling.type != node.type }
17
+ end
13
18
  end
14
19
  end
15
20
  end
@@ -10,6 +10,12 @@ module Prosemirror
10
10
  (1..6).each { |level| parse_html tag: "h#{level}", attrs: {level:} }
11
11
 
12
12
  def view_template(&) = tag(:"h#{@level}", &)
13
+
14
+ def to_markdown(markdown, _parent, _index)
15
+ markdown.write("#{"#" * @level} ")
16
+ markdown.render_inline(node, from_block_start: false)
17
+ markdown.close_block(node)
18
+ end
13
19
  end
14
20
  end
15
21
  end
@@ -7,6 +7,11 @@ module Prosemirror
7
7
  parse_html tag: "hr"
8
8
 
9
9
  def view_template = hr
10
+
11
+ def to_markdown(markdown, _parent, _index)
12
+ markdown.write("---")
13
+ markdown.close_block(node)
14
+ end
10
15
  end
11
16
  end
12
17
  end
@@ -17,6 +17,22 @@ module Prosemirror
17
17
  src = safe_url(@src)
18
18
  img(src: src, alt: @alt, title: @title, width: @width, height: @height) if src
19
19
  end
20
+
21
+ # `![alt](src "title")`, or HTML with `html: true` when it has dimensions. Images in a row share a line,
22
+ # as Markdown would give them back: in separate paragraphs, each would come back after an empty one.
23
+ def to_markdown(markdown, parent, index)
24
+ markdown.write(" ") if index.positive? && parent.content[index - 1].type == node.type
25
+ if markdown.html? && (@width || @height)
26
+ markdown.write(markdown.node_html(node))
27
+ else
28
+ markdown.write(self.class.markdown_image(markdown, @src, @alt, @title))
29
+ end
30
+ markdown.close_block(node) unless parent.content[index + 1]&.type == node.type
31
+ end
32
+
33
+ def self.markdown_image(markdown, src, alt, title)
34
+ "![#{markdown.esc(alt.to_s)}](#{markdown.url(src)}#{markdown.title(title)})"
35
+ end
20
36
  end
21
37
  end
22
38
  end
@@ -7,6 +7,10 @@ module Prosemirror
7
7
  parse_html tag: "li"
8
8
 
9
9
  def view_template(&) = li(&)
10
+
11
+ def to_markdown(markdown, _parent, _index)
12
+ markdown.render_content(node)
13
+ end
10
14
  end
11
15
  end
12
16
  end
@@ -31,6 +31,17 @@ module Prosemirror
31
31
  def view_template(&)
32
32
  ol(start: (@start unless @start == 1), type: @type, &)
33
33
  end
34
+
35
+ def to_markdown(markdown, parent, index)
36
+ self.class.numbered(markdown, node, @start, markdown.list_after_list?(parent, index) ? ")" : ".")
37
+ end
38
+
39
+ # Items numbered from `start`, with the numbers aligned. `)` instead of `.` keeps a list right after
40
+ # another ordered list apart from it.
41
+ def self.numbered(markdown, node, start, delimiter = ".")
42
+ width = (start + node.content.size - 1).to_s.length
43
+ markdown.render_list(node, " " * (width + 2)) { |index| "#{(start + index).to_s.rjust(width)}#{delimiter} " }
44
+ end
34
45
  end
35
46
  end
36
47
  end
@@ -8,6 +8,14 @@ module Prosemirror
8
8
  parse_html tag: "p"
9
9
 
10
10
  def view_template(&) = p(&)
11
+
12
+ # Markdown has no empty paragraphs: they are left out.
13
+ def to_markdown(markdown, _parent, _index)
14
+ return if node.content.empty?
15
+
16
+ markdown.render_inline(node)
17
+ markdown.close_block(node)
18
+ end
11
19
  end
12
20
  end
13
21
  end
@@ -10,6 +10,42 @@ module Prosemirror
10
10
  def view_template(&)
11
11
  table { tbody(&) }
12
12
  end
13
+
14
+ # A GitHub Flavored Markdown table: the first row is the header, and gives the columns' alignments. A
15
+ # cell's paragraphs, images and line breaks are joined with `<br>`, and its other blocks, like lists, are
16
+ # written as HTML, which Markdown import reads back with the herb gem.
17
+ def to_markdown(markdown, _parent, _index)
18
+ rows = node.content.map { |row| row.content.map { |cell| cell_markdown(markdown, cell) } }
19
+ columns = rows.map(&:size).max.to_i
20
+ return markdown.close_block(node) if columns.zero?
21
+
22
+ aligns = node.content.first.content.map { |cell| cell.attrs[:align] }
23
+ delimiter = Array.new(columns) do |index|
24
+ {"left" => ":--", "center" => ":-:", "right" => "--:"}.fetch(aligns[index].to_s, "---")
25
+ end
26
+ lines = [rows.first, delimiter, *rows.drop(1)].map do |cells|
27
+ "| #{Array.new(columns) { |index| cells[index].to_s }.join(" | ")} |"
28
+ end
29
+ markdown.text(lines.join("\n"), escape: false)
30
+ markdown.close_block(node)
31
+ end
32
+
33
+ private
34
+
35
+ def cell_markdown(markdown, cell)
36
+ parts = cell.content.map do |block|
37
+ type = markdown.schema.node_type(block.type)
38
+ if type && (type <= Paragraph || type <= Image)
39
+ serializer = MarkdownSerializer.new(markdown.schema, html: markdown.html?)
40
+ serializer.render(block, cell, 0)
41
+ serializer.out.strip.gsub(/\\\n|\n+/, "<br>")
42
+ else
43
+ markdown.node_html(block).gsub("\n", "&#10;")
44
+ end
45
+ end
46
+ # A space after a line break is dropped, as in HTML.
47
+ parts.join("<br>").gsub(/(?:<br>[[:space:]]*)+/, "<br>").delete_prefix("<br>").delete_suffix("<br>").gsub("|", "\\|")
48
+ end
13
49
  end
14
50
  end
15
51
  end
@@ -5,6 +5,10 @@ module Prosemirror
5
5
  # Text. Text nodes are rendered by the renderer, not by this type.
6
6
  class Text < NodeType
7
7
  group :inline
8
+
9
+ def to_markdown(markdown, _parent, _index)
10
+ markdown.text(node.text, escape: !markdown.in_autolink)
11
+ end
8
12
  end
9
13
  end
10
14
  end
@@ -57,6 +57,8 @@ module Prosemirror
57
57
  def after_initialize
58
58
  @node_lookup = lookup(@nodes)
59
59
  @mark_lookup = lookup(@marks)
60
+ @node_names = spellings(@node_lookup)
61
+ @mark_names = spellings(@mark_lookup)
60
62
  @textblocks = @nodes.to_h { |type| [type, textblock_type?(type)] }
61
63
  @content_matches = @nodes.to_h { |type| [type, ContentMatch.compile(type.content, self)] }
62
64
  raise ArgumentError, "the schema has no #{@top_node} node type" unless node_type(@top_node)
@@ -100,11 +102,11 @@ module Prosemirror
100
102
 
101
103
  # The node type for a name, alias, or camelCase name.
102
104
  def node_type(name)
103
- @node_lookup[Naming.snake(name).to_sym]
105
+ @node_names[name] || @node_lookup[Naming.snake(name).to_sym]
104
106
  end
105
107
 
106
108
  def mark_type(name)
107
- @mark_lookup[Naming.snake(name).to_sym]
109
+ @mark_names[name] || @mark_lookup[Naming.snake(name).to_sym]
108
110
  end
109
111
 
110
112
  # Build a document from JSON, given as a Hash or a String.
@@ -228,6 +230,16 @@ module Prosemirror
228
230
  end
229
231
  end
230
232
 
233
+ # The names of a lookup table, as Symbols and Strings, in snake_case and camelCase, so that most names
234
+ # are found without converting them.
235
+ def spellings(lookup)
236
+ table = {}
237
+ lookup.each { |name, type| table[name] = table[name.name] = type }
238
+ lookup.each { |name, type| table[Naming.camel(name).freeze] ||= type }
239
+ table.each_key.select { |name| name.is_a?(String) }.each { |name| table[name.to_sym] ||= table[name] }
240
+ table.freeze
241
+ end
242
+
231
243
  def merge_types(types, additions)
232
244
  additions.reduce(types) do |list, addition|
233
245
  index = list.index { |type| type.type_name == addition.type_name }
@@ -3,6 +3,7 @@
3
3
  module Prosemirror
4
4
  # Class-level declarations shared by node types and mark types.
5
5
  module TypeDefinition
6
+ EMPTY_HASH = {}.freeze
6
7
  # The name of the type, as a snake_case Symbol. It defaults to the class name (`BulletList` → `:bullet_list`),
7
8
  # and subclasses of a concrete type keep its name.
8
9
  def type_name(name = nil)
@@ -24,7 +25,12 @@ module Prosemirror
24
25
  # - `parse_html:` a block reading the attribute from an HTML element, when importing HTML. By default,
25
26
  # the HTML attribute with the JSON name is read.
26
27
  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
28
+ json = (json || name).to_s
29
+ aliases = aliases.map(&:to_s)
30
+ # The keys accepted in JSON: each name as written, in snake_case and in camelCase.
31
+ keys = [json, *aliases].flat_map { |key| [key, Naming.snake(key), Naming.camel(key)] }.uniq.map(&:freeze).freeze
32
+ attribute_names[name] = {json:, aliases:, parse_html:, keys:}.freeze
33
+ @known_keys = nil
28
34
  super(name, type, kind, **, &)
29
35
  end
30
36
 
@@ -54,7 +60,10 @@ module Prosemirror
54
60
  # Invalid values are converted when the meaning is clear, or replaced by their default.
55
61
  # Returns nil when a required attribute is missing or invalid.
56
62
  def build_attrs(given, report:)
57
- given = given.is_a?(Hash) ? given.transform_keys(&:to_s) : {}
63
+ given = if !given.is_a?(Hash) then EMPTY_HASH
64
+ elsif keys?(given) { |key| key.is_a?(String) } then given
65
+ else given.transform_keys(&:to_s)
66
+ end
58
67
 
59
68
  literal_properties.each_with_object({}) do |property, attrs|
60
69
  json_name = attribute_names.dig(property.name, :json)
@@ -86,14 +95,19 @@ module Prosemirror
86
95
 
87
96
  # The JSON attributes this type doesn't declare, with String keys, so that `to_h` gives them back.
88
97
  def unknown_attrs(given)
89
- return {} unless given.is_a?(Hash)
98
+ return EMPTY_HASH unless given.is_a?(Hash)
99
+
100
+ known = known_keys
101
+ return EMPTY_HASH if keys?(given) { |key| known.include?(key.is_a?(Symbol) ? key.name : key) }
90
102
 
91
- known = attribute_names.values.flat_map do |names|
92
- [names[:json], *names[:aliases]].flat_map { |name| [name, Naming.snake(name), Naming.camel(name)] }
93
- end
94
103
  given.transform_keys(&:to_s).except(*known)
95
104
  end
96
105
 
106
+ # The JSON keys of the declared attributes, in all their spellings.
107
+ def known_keys
108
+ @known_keys ||= attribute_names.each_value.flat_map { |names| names[:keys] }.to_set.freeze
109
+ end
110
+
97
111
  # Attributes as JSON, with their JSON names.
98
112
  def serialize_attrs(attrs)
99
113
  attrs.to_h { |name, value| [attribute_names.dig(name, :json) || name.to_s, value] }
@@ -122,17 +136,23 @@ module Prosemirror
122
136
  superclass.send(:inherited_setting, ivar, default)
123
137
  end
124
138
 
139
+ # Whether the block is true for all keys of the hash, without the Enumerator `each_key.all?` allocates.
140
+ def keys?(hash)
141
+ hash.each_key { |key| return false unless yield(key) }
142
+ true
143
+ end
144
+
125
145
  def attribute_key(given, names)
126
- [names[:json], *names[:aliases]].each do |name|
127
- [name, Naming.snake(name), Naming.camel(name)].each do |candidate|
128
- return candidate if given.key?(candidate)
129
- end
130
- end
131
- nil
146
+ names[:keys].find { |key| given.key?(key) }
132
147
  end
133
148
 
149
+ # The gem's coercions don't use `self`: they are called directly, without Literal's `instance_exec`.
134
150
  def coerce_attribute(property, value)
135
- property.coercion ? property.coerce(value, context: self) : value
151
+ coercion = property.coercion
152
+ if coercion.nil? then value
153
+ elsif Coerce::PLAIN.include?(coercion) then coercion.call(value)
154
+ else property.coerce(value, context: self)
155
+ end
136
156
  rescue
137
157
  value
138
158
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prosemirror
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans Lemuet
@@ -84,6 +84,7 @@ files:
84
84
  - lib/prosemirror/mark.rb
85
85
  - lib/prosemirror/mark_type.rb
86
86
  - lib/prosemirror/markdown/builder.rb
87
+ - lib/prosemirror/markdown_serializer.rb
87
88
  - lib/prosemirror/marks/basic/code.rb
88
89
  - lib/prosemirror/marks/basic/em.rb
89
90
  - lib/prosemirror/marks/basic/link.rb