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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c84fca02d8a8c1f32c9162da3a22133f2ef774c035281331d7d92423d24a242f
4
- data.tar.gz: ca2ec139725747838a359f43a667c1fc4b5b5249b7dba12775ffbe39985f0c38
3
+ metadata.gz: 3717422ef93a6f35012d264a8d82b3fe9c2982631d5b2f39a499be9ad2da79a7
4
+ data.tar.gz: 05af2f5ff65a7a07e0f1134627fd6e4110d089264b9df1ad78f66000c4bda30e
5
5
  SHA512:
6
- metadata.gz: c018119a21927872e1382b1ee05f33c397781c87520b55ee4ca76f06abc2fb728ed4122043f11250cd3f8195c5ede8de4e50619bf98f93405f163fa30bd413aa
7
- data.tar.gz: 3a870e5aceacd20c1ec717942f3dc4c75a97f811e8813e73c346aa71b919ab4430645593c6daec6132a1c72cd3f053e192ff812126cf22cd8f567cc5cafb8b98
6
+ metadata.gz: 950422c7a48833915def7682efdb216a93c416acbfcb57ea712da29d220280a269d524013cc5bff57c0cc706fa243b7e0cd7080985bc8bd129224f182b1e4c62
7
+ data.tar.gz: 3914858a0eadcabd1d17dc253259a2c4786cb22332aff14b66c3d8fe89f9ea99de7c0ea4c6e70255fb23b9b992b543bd22bde54fdb2f55dbb6232185ba306168
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-10-24
4
+
5
+ - `Document#to_markdown` writes Markdown, with GitHub Flavored Markdown tables and strikethrough, like prosemirror-markdown. Node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`. `html: true` writes underline, highlight, subscript, superscript and image sizes as HTML.
6
+ - `Document#check` accepts attributes whose JSON name differs from their property name, like a link's `class`. It reported them as invalid.
7
+ - Faster: parsing JSON takes less than half the time, rendering and `to_h` about a third less, and HTML and Markdown import about a quarter less, with the same results. Strings in documents parsed from a JSON string are frozen, including the values of unknown attributes.
8
+
3
9
  ## [0.4.0] - 2026-10-24
4
10
 
5
11
  - `Document#transform` builds a new document from a block called with each node, children first, which keeps, replaces, removes or splices nodes. Required content that goes missing is added back and reported.
data/README.md CHANGED
@@ -24,6 +24,7 @@ doc = Prosemirror.parse(record.content) # a Hash or a JSON string
24
24
 
25
25
  doc.to_html # => "<h2>Hello</h2><p>World</p>"
26
26
  doc.to_text # => "Hello\n\nWorld"
27
+ doc.to_markdown # => "## Hello\n\nWorld\n"
27
28
  doc.to_h # => { "type" => "doc", "content" => [...] }
28
29
  ```
29
30
 
@@ -117,6 +118,23 @@ Raw HTML in Markdown goes through HTML import when the herb gem is installed, an
117
118
 
118
119
  YAML front matter, between `---` lines at the start, is dropped. Footnotes aren't supported yet: `[^1]: Note` reads as a link definition, as in CommonMark. Markdown nested more than 1,000 levels deep raises `Prosemirror::Error`.
119
120
 
121
+ ## Markdown export
122
+
123
+ `to_markdown` writes the document as Markdown, with GitHub Flavored Markdown for tables and strikethrough, like [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown). It needs no gem.
124
+
125
+ ```ruby
126
+ doc.to_markdown # => "## Hello\n\n**World**\n"
127
+ doc.to_markdown(html: true) # underline, highlight, subscript and superscript as HTML
128
+ ```
129
+
130
+ - Text is escaped, so that it reads back as text: `*`, `_`, `[`, a `#` or `-` that would start a block, HTML-like `<b>`, and URLs that GitHub would turn into links.
131
+ - Markdown has no syntax for underline, highlight, subscript, superscript or image sizes: by default, only their text or image is written. With `html: true`, they are written as HTML.
132
+ - Emphasis that CommonMark can't write where it is, like bold on a comma between two letters, is dropped and its text kept.
133
+ - Tables are written as GFM tables. A cell's paragraphs and line breaks are joined with `<br>`, and its other blocks, like lists, are written as HTML.
134
+ - Empty paragraphs are left out.
135
+
136
+ Importing the Markdown gives the document back, as far as Markdown can hold it. Custom types write their own Markdown: see [Node and mark types](TYPES.md#markdown-export).
137
+
120
138
  ## Text import
121
139
 
122
140
  ```ruby
data/ROADMAP.md CHANGED
@@ -192,10 +192,12 @@ They differ in more than names:
192
192
  ```
193
193
  - Validation against the schema (`check`).
194
194
 
195
- ### 0.5: Markdown export
195
+ ### 0.5: Markdown export (released)
196
196
 
197
- - `doc.to_markdown`, like [`prosemirror-markdown`](https://github.com/ProseMirror/prosemirror-markdown). Useful to send content to LLMs.
198
- - Help wanted: it is self-contained and a good first contribution.
197
+ - `doc.to_markdown`, ported from [`prosemirror-markdown`](https://github.com/ProseMirror/prosemirror-markdown)'s serializer. Useful to send content to LLMs.
198
+ - GitHub Flavored Markdown tables and strikethrough. Formatting without Markdown syntax, like underline, keeps its text, or is written as HTML with `html: true`.
199
+ - Custom node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`.
200
+ - Importing the Markdown gives the document back, as far as Markdown can hold it, tested on real-world Markdown and HTML.
199
201
 
200
202
  ### 0.6: More Tiptap node types
201
203
 
data/TYPES.md CHANGED
@@ -27,6 +27,7 @@ A type is a [Phlex](https://www.phlex.fun) component, and its attributes are [Li
27
27
  - [Rendering](#rendering)
28
28
  - [Parse rules](#parse-rules)
29
29
  - [Markdown](#markdown)
30
+ - [Markdown export](#markdown-export)
30
31
  - [Subclassing built-in types](#subclassing-built-in-types)
31
32
 
32
33
  ## Names
@@ -203,6 +204,48 @@ Markdown import builds the elements commonmarker renders for each Markdown node,
203
204
 
204
205
  Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
205
206
 
207
+ ## Markdown export
208
+
209
+ A node type writes itself in `to_markdown`, with the methods of `Prosemirror::MarkdownSerializer`, ported from prosemirror-markdown's `MarkdownSerializerState`. As in `view_template`, attributes are instance variables, and `node` is the node:
210
+
211
+ ```ruby
212
+ class Callout < Prosemirror::NodeType
213
+ def to_markdown(markdown, parent, index)
214
+ markdown.wrap_block("> ", "> **Note:** ", node) { markdown.render_content(node) }
215
+ end
216
+ end
217
+ ```
218
+
219
+ | Method | Does |
220
+ |---|---|
221
+ | `write(text)` | Writes text as it is, after closing the previous block. |
222
+ | `text(text, escape: true)` | Writes text, escaped. |
223
+ | `render_inline(node)` | Writes the node's inline content, with its marks. |
224
+ | `render_content(node)` | Writes the node's blocks. |
225
+ | `wrap_block(delim, first_delim, node) { … }` | Writes a block with each line prefixed, like `> `. |
226
+ | `render_list(node, delim) { \|index\| marker }` | Writes a list. |
227
+ | `close_block(node)` | Ends the block: the next one starts after a blank line. |
228
+ | `esc(text)`, `url(href)`, `node_html(node)`, `html?` | Escaping, a link destination, a node's HTML, and whether `html: true` was given. |
229
+
230
+ Without `to_markdown`, a type writes its content.
231
+
232
+ A mark type declares its syntax with `markdown`, as prosemirror-markdown's mark specs:
233
+
234
+ ```ruby
235
+ class Keyboard < Prosemirror::MarkType
236
+ markdown open: "<kbd>", close: "</kbd>", escape: false
237
+ end
238
+ ```
239
+
240
+ | Option | Meaning |
241
+ |---|---|
242
+ | `open`, `close` | Strings, or blocks called with the serializer, the mark, the parent node and the index of the marked node. |
243
+ | `mixable: true` | The mark can open and close in any order with other mixable marks, like `*` and `**`. |
244
+ | `expel_enclosing_whitespace: true` | Spaces at the edges move outside the mark, as emphasis requires. |
245
+ | `escape: false` | The content isn't escaped, as in inline code. The mark must come last. |
246
+
247
+ Without `markdown`, a mark writes its text, or its HTML with `to_markdown(html: true)`.
248
+
206
249
  ## Subclassing built-in types
207
250
 
208
251
  A subclass keeps the name, options, attributes and parse rules of its parent, and can change any of them. Register it with `Schema#with`, which replaces the built-in type with the same name:
@@ -63,10 +63,10 @@ module Prosemirror
63
63
  invalid("invalid marks: #{node.marks.map { |mark| @schema.json_type_name(mark.type) }.join(" ")}", path) unless sorted == node.marks
64
64
  end
65
65
 
66
- # Attributes are valid when building them from themselves reports nothing and changes nothing.
66
+ # Attributes are valid when building them from their JSON reports nothing and changes nothing.
67
67
  def check_attrs(type, attrs, path)
68
68
  problems = []
69
- built = type.build_attrs(attrs, report: ->(message) { problems << message })
69
+ built = type.build_attrs(type.serialize_attrs(attrs), report: ->(message) { problems << message })
70
70
  invalid(problems.first, path) unless problems.empty?
71
71
  invalid("invalid attributes: #{attrs.inspect}", path) unless built == attrs
72
72
  end
@@ -13,5 +13,12 @@ module Prosemirror
13
13
 
14
14
  # A frozen copy, unless the value is already frozen.
15
15
  FROZEN = proc { |value| value.frozen? ? value : value.dup.freeze }
16
+
17
+ # The coercions above, which don't use `self`.
18
+ PLAIN = [INTEGER, SYMBOL, FROZEN].freeze
19
+
20
+ def self.frozen(value)
21
+ value.frozen? ? value : value.dup.freeze
22
+ end
16
23
  end
17
24
  end
@@ -47,6 +47,12 @@ module Prosemirror
47
47
  TextSerializer.new(schema, block_separator: block_separator).call(root)
48
48
  end
49
49
 
50
+ # The document as Markdown (GitHub Flavored Markdown for tables and strikethrough). Formatting Markdown
51
+ # has no syntax for, like underline, keeps only its text, or is written as HTML with `html: true`.
52
+ def to_markdown(html: false)
53
+ MarkdownSerializer.new(schema, html: html).call(root)
54
+ end
55
+
50
56
  # The document as JSON, as a Hash.
51
57
  def to_h
52
58
  schema.serialize(root)
@@ -33,7 +33,7 @@ module Prosemirror
33
33
  end
34
34
 
35
35
  def classes
36
- self["class"].to_s.split
36
+ @classes ||= self["class"].to_s.split.freeze
37
37
  end
38
38
 
39
39
  # The declarations of the `style` attribute, with lowercase properties: `{"font-weight" => "bold"}`.
@@ -15,22 +15,30 @@ module Prosemirror
15
15
 
16
16
  def initialize(source)
17
17
  @source = source
18
- @compounds = source.split(",").map { |part| parse_compound(part.strip) }
18
+ @names = []
19
+ @compounds = source.split(",").map { |part| parse_compound(part.strip, top: true) }
20
+ @names = @names.include?(nil) ? nil : @names.uniq.freeze
19
21
  end
20
22
 
21
23
  def match?(element)
22
24
  @compounds.any? { |tests| tests.all? { |test| test.call(element) } }
23
25
  end
24
26
 
27
+ # The element names it can match, or nil when it can match any element.
28
+ attr_reader :names
29
+
25
30
  private
26
31
 
27
- def parse_compound(part)
32
+ def parse_compound(part, top: false)
28
33
  invalid! if part.empty?
29
34
 
30
35
  tests = []
31
36
  rest = part
32
- if (type = rest[/\A(?:\*|#{IDENTIFIER})/o])
33
- tests << ->(element) { element.name == type.downcase } unless type == "*"
37
+ type = rest[/\A(?:\*|#{IDENTIFIER})/o]
38
+ name = type.downcase.freeze if type && type != "*"
39
+ @names << name if top
40
+ if type
41
+ tests << ->(element) { element.name == name } if name
34
42
  rest = rest[type.length..]
35
43
  end
36
44
  until rest.empty?
@@ -34,6 +34,11 @@ module Prosemirror
34
34
  rules = (schema.marks + schema.nodes).flat_map(&:parse_rules)
35
35
  rules = rules.each_with_index.sort_by { |rule, index| [-rule.priority, index] }.map(&:first)
36
36
  @tags = rules.select(&:tag)
37
+ @selectors = @tags.map { |rule| HTML::Selector.parse(rule.tag) }
38
+ # The indexes of the tag rules that can match an element with this name, in order.
39
+ @candidates = Hash.new do |candidates, name|
40
+ candidates[name] = @selectors.each_index.select { |index| @selectors[index].names.nil? || @selectors[index].names.include?(name) }.freeze
41
+ end
37
42
  @styles = rules.select(&:style)
38
43
  @matched_styles = @styles.map(&:style_property).uniq
39
44
  @normalize_lists = @tags.none? { |rule| lists_contain_themselves?(rule) }
@@ -53,9 +58,11 @@ module Prosemirror
53
58
 
54
59
  # The first tag rule after `after` that matches the element in this context.
55
60
  def match_tag(element, context, after = nil)
56
- ((after ? after + 1 : 0)...@tags.size).each do |index|
61
+ @candidates[element.name].each do |index|
62
+ next if after && index <= after
63
+
57
64
  rule = @tags[index]
58
- next unless HTML::Selector.parse(rule.tag).match?(element)
65
+ next unless @selectors[index].match?(element)
59
66
  next if rule.context && !context.matches_context?(rule.context)
60
67
 
61
68
  match = build_match(rule, index, element) { |get_attrs| call_get_attrs(get_attrs, element) }
@@ -6,19 +6,26 @@ module Prosemirror
6
6
  # Invalid parts are fixed and reported to the schema (which raises in strict mode).
7
7
  class JSONParser
8
8
  MAX_DEPTH = 100
9
+ EMPTY = [].freeze
9
10
 
10
11
  def initialize(schema)
11
12
  @schema = schema
13
+ # The path of the node being built, as `["doc", :content, 0, :marks, 1]`: a string is only made for
14
+ # a report.
15
+ @path = []
16
+ @report = ->(message) { invalid(message) }
12
17
  end
13
18
 
14
19
  def parse(json)
15
- hash = json.is_a?(String) ? JSON.parse(json, max_nesting: (MAX_DEPTH * 2) + 2) : json
20
+ # Frozen strings go into nodes without being copied.
21
+ hash = json.is_a?(String) ? JSON.parse(json, max_nesting: (MAX_DEPTH * 2) + 2, freeze: true) : json
16
22
  raise ArgumentError, "expected a Hash or a JSON String, got #{json.class}" unless hash.is_a?(Hash)
17
23
 
18
- root = build_node(hash, "doc", 0) || Node.new(type: @schema.top_node)
24
+ @path = ["doc"]
25
+ root = build_node(hash, 0) || Node.new(type: @schema.top_node)
19
26
  return root if root.type == @schema.top_node
20
27
 
21
- @schema.report("the top node is #{root.type}, not #{@schema.top_node}", path: "doc")
28
+ invalid("the top node is #{root.type}, not #{@schema.top_node}")
22
29
  Node.new(type: @schema.top_node, content: [root])
23
30
  rescue JSON::NestingError
24
31
  raise InvalidDocument, "the document is nested more than #{MAX_DEPTH} levels deep"
@@ -26,88 +33,112 @@ module Prosemirror
26
33
 
27
34
  private
28
35
 
29
- def build_node(hash, path, depth)
30
- return invalid("not a node: #{hash.inspect}", path) unless hash.is_a?(Hash)
31
- return invalid("nested more than #{MAX_DEPTH} levels deep", path) if depth > MAX_DEPTH
36
+ def build_node(hash, depth)
37
+ return invalid("not a node: #{hash.inspect}") unless hash.is_a?(Hash)
38
+ return invalid("nested more than #{MAX_DEPTH} levels deep") if depth > MAX_DEPTH
32
39
 
33
40
  name = fetch(hash, "type")
34
- return invalid("missing node type", path) unless name.is_a?(String) || name.is_a?(Symbol)
35
- return build_text(hash, path) if name.to_s == "text"
41
+ return invalid("missing node type") unless name.is_a?(String) || name.is_a?(Symbol)
42
+ return build_text(hash) if name == "text" || name == :text
36
43
 
37
44
  node_type = @schema.node_type(name)
38
- return build_unknown(hash, name, path, depth) unless node_type
45
+ return build_unknown(hash, name, depth) unless node_type
39
46
 
40
47
  given = fetch(hash, "attrs")
41
- attrs = node_type.build_attrs(given, report: reporter(path))
42
- return invalid("removed #{name} node", path) unless attrs
48
+ attrs = node_type.build_attrs(given, report: @report)
49
+ return invalid("removed #{name} node") unless attrs
43
50
 
44
- Node.new(type: node_type.type_name, attrs: attrs, unknown_attrs: node_type.unknown_attrs(given),
45
- content: build_content(hash, path, depth), marks: build_marks(fetch(hash, "marks"), path))
51
+ Node.new(type: node_type.type_name, attrs: attrs.freeze, unknown_attrs: node_type.unknown_attrs(given).freeze,
52
+ content: build_content(hash, depth), marks: build_marks(fetch(hash, "marks")))
46
53
  end
47
54
 
48
- def build_text(hash, path)
55
+ def build_text(hash)
49
56
  text = fetch(hash, "text")
50
- return invalid("text node without text", path) unless text.is_a?(String)
57
+ return invalid("text node without text") unless text.is_a?(String)
51
58
  return nil if text.empty?
52
59
 
53
- Node.new(type: :text, text: text, marks: build_marks(fetch(hash, "marks"), path))
60
+ Node.new(type: :text, text: text, marks: build_marks(fetch(hash, "marks")))
54
61
  end
55
62
 
56
- def build_unknown(hash, name, path, depth)
57
- invalid("unknown node type #{name}", path)
63
+ def build_unknown(hash, name, depth)
64
+ invalid("unknown node type #{name}")
58
65
  attrs = fetch(hash, "attrs")
59
66
  Node.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {},
60
- content: build_content(hash, path, depth), marks: build_marks(fetch(hash, "marks"), path))
67
+ content: build_content(hash, depth), marks: build_marks(fetch(hash, "marks")))
61
68
  end
62
69
 
63
- def build_content(hash, path, depth)
70
+ def build_content(hash, depth)
64
71
  content = fetch(hash, "content")
65
- return [] if content.nil?
66
- return invalid("content is not a list", path) || [] unless content.is_a?(Array)
72
+ return EMPTY if content.nil?
73
+ return invalid("content is not a list") || EMPTY unless content.is_a?(Array)
74
+
75
+ nodes = []
76
+ content.each_with_index do |child, index|
77
+ @path.push(:content, index)
78
+ node = build_node(child, depth + 1)
79
+ @path.pop
80
+ @path.pop
81
+ nodes << node if node
82
+ end
83
+ Node.join_text(nodes).freeze
84
+ end
67
85
 
68
- nodes = content.each_with_index.filter_map do |child, index|
69
- build_node(child, "#{path}.content[#{index}]", depth + 1)
86
+ def build_marks(marks)
87
+ return EMPTY if marks.nil?
88
+ return invalid("marks is not a list") || EMPTY unless marks.is_a?(Array)
89
+
90
+ built = []
91
+ marks.each_with_index do |mark, index|
92
+ @path.push(:marks, index)
93
+ mark = build_mark(mark)
94
+ @path.pop
95
+ @path.pop
96
+ built << mark if mark
70
97
  end
71
- Node.join_text(nodes)
98
+ sort_marks(built).freeze
72
99
  end
73
100
 
74
- def build_marks(marks, path)
75
- return [] if marks.nil?
76
- return invalid("marks is not a list", path) || [] unless marks.is_a?(Array)
101
+ # The marks without duplicates, in schema order. Most sets have one mark, or are already sorted.
102
+ def sort_marks(marks)
103
+ return marks if marks.size < 2
104
+
105
+ marks = marks.uniq
106
+ ranks = marks.map { |mark| @schema.mark_rank(mark.type) }
107
+ return marks if ranks.each_cons(2).all? { |a, b| a <= b }
77
108
 
78
- built = marks.each_with_index.filter_map { |mark, index| build_mark(mark, "#{path}.marks[#{index}]") }
79
- built.uniq.sort_by.with_index { |mark, index| [@schema.mark_rank(mark.type), index] }
109
+ marks.each_with_index.sort_by { |mark, index| [ranks[index], index] }.map(&:first)
80
110
  end
81
111
 
82
- def build_mark(hash, path)
83
- return invalid("not a mark: #{hash.inspect}", path) unless hash.is_a?(Hash)
112
+ def build_mark(hash)
113
+ return invalid("not a mark: #{hash.inspect}") unless hash.is_a?(Hash)
84
114
 
85
115
  name = fetch(hash, "type")
86
- return invalid("missing mark type", path) unless name.is_a?(String) || name.is_a?(Symbol)
116
+ return invalid("missing mark type") unless name.is_a?(String) || name.is_a?(Symbol)
87
117
 
88
118
  mark_type = @schema.mark_type(name)
89
119
  unless mark_type
90
- invalid("unknown mark type #{name}", path)
120
+ invalid("unknown mark type #{name}")
91
121
  attrs = fetch(hash, "attrs")
92
122
  return Mark.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {})
93
123
  end
94
124
 
95
125
  given = fetch(hash, "attrs")
96
- attrs = mark_type.build_attrs(given, report: reporter(path))
97
- return invalid("removed #{name} mark", path) unless attrs
126
+ attrs = mark_type.build_attrs(given, report: @report)
127
+ return invalid("removed #{name} mark") unless attrs
98
128
 
99
- Mark.new(type: mark_type.type_name, attrs: attrs, unknown_attrs: mark_type.unknown_attrs(given))
129
+ Mark.new(type: mark_type.type_name, attrs: attrs.freeze, unknown_attrs: mark_type.unknown_attrs(given).freeze)
100
130
  end
101
131
 
102
132
  def fetch(hash, key)
103
133
  hash.key?(key) ? hash[key] : hash[key.to_sym]
104
134
  end
105
135
 
106
- def reporter(path)
107
- ->(message) { @schema.report(message, path: path) }
136
+ # The path as written in reports: `doc.content[0].marks[1]`.
137
+ def path
138
+ ([@path.first] + @path.drop(1).each_slice(2).map { |name, index| "#{name}[#{index}]" }).join(".")
108
139
  end
109
140
 
110
- def invalid(message, path)
141
+ def invalid(message)
111
142
  @schema.report(message, path: path)
112
143
  end
113
144
  end
@@ -7,9 +7,14 @@ module Prosemirror
7
7
  #
8
8
  # As for nodes, `unknown_attrs` holds the attributes the mark's type doesn't declare.
9
9
  class Mark < Literal::Data
10
- prop :type, Symbol, &Coerce::SYMBOL
11
- prop :attrs, _Hash(Symbol, _Any?), default: {}.freeze, &Coerce::FROZEN
12
- prop :unknown_attrs, _Hash(String, _Any?), default: {}.freeze, &Coerce::FROZEN
10
+ prop :type, Symbol
11
+ prop :attrs, _Hash(Symbol, _Any?), default: Node::EMPTY_HASH
12
+ prop :unknown_attrs, _Hash(String, _Any?), default: Node::EMPTY_HASH
13
+
14
+ # As Node#initialize.
15
+ def initialize(type:, attrs: Node::EMPTY_HASH, unknown_attrs: Node::EMPTY_HASH)
16
+ super(type: type.is_a?(String) ? type.to_sym : type, attrs: Coerce.frozen(attrs), unknown_attrs: Coerce.frozen(unknown_attrs))
17
+ end
13
18
 
14
19
  # A copy with some properties changed.
15
20
  def with(**changes)
@@ -44,6 +44,21 @@ module Prosemirror
44
44
  inherited_setting(:@group, [])
45
45
  end
46
46
 
47
+ # The mark's Markdown syntax, as prosemirror-markdown's MarkSerializerSpec:
48
+ # - `open` and `close`: strings, or blocks called with the MarkdownSerializer, the mark, the parent node
49
+ # and the index of the marked node;
50
+ # - `mixable`: the mark can open and close in any order with other mixable marks, like `*` and `**`;
51
+ # - `expel_enclosing_whitespace`: spaces at the edges move outside the mark, as emphasis requires;
52
+ # - `escape: false`: the content isn't escaped, as in inline code. The mark must come last.
53
+ # Without it, the mark keeps only its text, or its HTML with `to_markdown(html: true)`.
54
+ def markdown(open:, close:, mixable: false, expel_enclosing_whitespace: false, escape: true)
55
+ @markdown_spec = {open:, close:, mixable:, expel_enclosing_whitespace:, escape:}.freeze
56
+ end
57
+
58
+ def markdown_spec
59
+ inherited_setting(:@markdown_spec, nil)
60
+ end
61
+
47
62
  # Whether one element can wrap several adjacent nodes with this mark. Defaults to true.
48
63
  def spanning(value = nil)
49
64
  return @spanning = value unless value.nil?
@@ -50,6 +50,14 @@ module Prosemirror
50
50
  root
51
51
  end
52
52
 
53
+ # The URL as commonmarker renders it, with the characters URLs can't hold percent-encoded. Markdown export
54
+ # writes URLs this way too, so that importing its output changes nothing.
55
+ def self.escape_url(url)
56
+ host = url[IPV6_HOST].to_s
57
+ rest = url.delete_prefix(host).b.gsub(URL_UNSAFE) { |byte| byte.ord.zero? ? "%EF%BF%BD" : format("%%%02X", byte.ord) }
58
+ host + rest.force_encoding(Encoding::UTF_8)
59
+ end
60
+
53
61
  private
54
62
 
55
63
  def parse(markdown)
@@ -78,8 +86,12 @@ module Prosemirror
78
86
 
79
87
  # Adds a node to the target. Returns the element to add its children to, when they need adding.
80
88
  def add(node, target)
81
- case node.type
82
- when *TAGS.keys then HTML::Element.new(TAGS[node.type])
89
+ type = node.type # commonmarker allocates for each call
90
+ if (tag = TAGS[type])
91
+ return HTML::Element.new(tag)
92
+ end
93
+
94
+ case type
83
95
  when :heading then HTML::Element.new("h#{node.header_level}")
84
96
  when :list then list(node)
85
97
  when :code_block then code_block(node, target)
@@ -157,7 +169,10 @@ module Prosemirror
157
169
  end
158
170
 
159
171
  def raw_html?(document)
160
- document.walk { |node| return true if node.type == :html_block || node.type == :html_inline }
172
+ document.walk do |node|
173
+ type = node.type
174
+ return true if type == :html_block || type == :html_inline
175
+ end
161
176
  false
162
177
  end
163
178
 
@@ -181,12 +196,7 @@ module Prosemirror
181
196
  end
182
197
  end
183
198
 
184
- # The URL as commonmarker renders it, with the characters URLs can't hold percent-encoded.
185
- def escape_url(url)
186
- host = url[IPV6_HOST].to_s
187
- rest = url.delete_prefix(host).b.gsub(URL_UNSAFE) { |byte| byte.ord.zero? ? "%EF%BF%BD" : format("%%%02X", byte.ord) }
188
- host + rest.force_encoding(Encoding::UTF_8)
189
- end
199
+ def escape_url(url) = self.class.escape_url(url)
190
200
 
191
201
  def plain_text(node)
192
202
  node.to_a.map { |child| %i[text code].include?(child.type) ? child.string_content : plain_text(child) }.join