prosemirror 0.4.0 → 0.6.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +49 -4
  4. data/ROADMAP.md +9 -5
  5. data/TYPES.md +55 -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 +65 -13
  16. data/lib/prosemirror/markdown_serializer.rb +518 -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 +15 -0
  33. data/lib/prosemirror/nodes/code_block.rb +15 -0
  34. data/lib/prosemirror/nodes/details.rb +27 -0
  35. data/lib/prosemirror/nodes/details_content.rb +12 -0
  36. data/lib/prosemirror/nodes/details_summary.rb +12 -0
  37. data/lib/prosemirror/nodes/hard_break.rb +5 -0
  38. data/lib/prosemirror/nodes/heading.rb +6 -0
  39. data/lib/prosemirror/nodes/horizontal_rule.rb +5 -0
  40. data/lib/prosemirror/nodes/image.rb +16 -0
  41. data/lib/prosemirror/nodes/list_item.rb +4 -0
  42. data/lib/prosemirror/nodes/mention.rb +36 -0
  43. data/lib/prosemirror/nodes/ordered_list.rb +11 -0
  44. data/lib/prosemirror/nodes/paragraph.rb +8 -0
  45. data/lib/prosemirror/nodes/table.rb +36 -0
  46. data/lib/prosemirror/nodes/task_item.rb +40 -0
  47. data/lib/prosemirror/nodes/task_list.rb +24 -0
  48. data/lib/prosemirror/nodes/text.rb +4 -0
  49. data/lib/prosemirror/nodes/twitch.rb +94 -0
  50. data/lib/prosemirror/nodes/youtube.rb +71 -0
  51. data/lib/prosemirror/schema.rb +22 -6
  52. data/lib/prosemirror/text_align.rb +31 -0
  53. data/lib/prosemirror/type_definition.rb +33 -13
  54. data/lib/prosemirror/version.rb +1 -1
  55. metadata +11 -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: 8aae1987528266c7bd7c4b3c3d8f7c82e74f74cf89ee9e7ac87b1732ff76634d
4
+ data.tar.gz: d5cb877b264eb0d39cd40cd8b4909ec94b423ac439a6719df23c70eb6cde6182
5
5
  SHA512:
6
- metadata.gz: c018119a21927872e1382b1ee05f33c397781c87520b55ee4ca76f06abc2fb728ed4122043f11250cd3f8195c5ede8de4e50619bf98f93405f163fa30bd413aa
7
- data.tar.gz: 3a870e5aceacd20c1ec717942f3dc4c75a97f811e8813e73c346aa71b919ab4430645593c6daec6132a1c72cd3f053e192ff812126cf22cd8f567cc5cafb8b98
6
+ metadata.gz: 947eab5fdc041f059be90be81b94bc66bb1505118b983ed982100faf1ccf2748fdcf753f83384385af72e80cc610473e24eeca31aa6a404ed93f431a8d3138d2
7
+ data.tar.gz: 4a56748f16cc7ac1af9ad1660518870dfded5f22b089e714417cf74afb970e0ec8b9f53d2d9e1adc82662efac63c449ba1dff10d07988ed8ca110413143c2452
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-10-24
4
+
5
+ - Tiptap's YouTube and Twitch embeds, in `Schema.tiptap`: `youtube` and `twitch`, with the video's URL in `src`, rendered as an iframe of its embed URL, built from the video's id. They are imported from Tiptap's markup, and from iframes of embed URLs without it. Subclasses set Twitch's `parent` domain and YouTube's nocookie host. Markdown export writes a link to the video, or the iframe with `html: true`.
6
+ - Tiptap's details, in `Schema.tiptap`: `details`, `details_summary` and `details_content`, rendered and imported as Tiptap does, including `<details>` without Tiptap's content wrapper. `open`, which Tiptap keeps with `persist: true`, is rendered and imported. Markdown export writes them as `<details>` HTML around Markdown content, as GitHub reads it.
7
+ - `Schema.tiptap(text_align: true)` adds Tiptap's TextAlign extension to headings and paragraphs: a `textAlign` attribute, rendered as a `text-align` style, and imported from it or from the old `align` attribute. Other types get it by including `Prosemirror::TextAlign`.
8
+ - Tiptap's task lists, in `Schema.tiptap`: `task_list` and `task_item`, with a `checked` attribute. They render Tiptap's HTML, and are imported from it and from GitHub's. Markdown import reads GitHub Flavored Markdown task lists: a bullet list whose items all have a checkbox is a task list, and other checkboxes stay as text. Markdown export writes `- [x]` items.
9
+ - Tiptap's mention node, in `Schema.tiptap`: rendered as `<span data-type="mention">@label</span>`, with its `id`, `label` and `mentionSuggestionChar` in `data-` attributes, and imported from that HTML. Its text is `@label`, or `@id` without a label. In Markdown, it's written as text, or as HTML with `html: true`.
10
+
11
+ ## [0.5.0] - 2026-10-24
12
+
13
+ - `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.
14
+ - `Document#check` accepts attributes whose JSON name differs from their property name, like a link's `class`. It reported them as invalid.
15
+ - 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.
16
+
3
17
  ## [0.4.0] - 2026-10-24
4
18
 
5
19
  - `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
 
@@ -108,15 +109,34 @@ bundle add commonmarker
108
109
  doc = Prosemirror.from_markdown("## Hello\n\nSome **bold** text.")
109
110
  ```
110
111
 
111
- It reads GitHub Flavored Markdown: tables, strikethrough and autolinks. Each Markdown node becomes the element commonmarker renders for it, and the schema's parse rules apply as in HTML import, so custom types can match Markdown too: a type that matches `img` also gets Markdown images. For the built-in types, it gives the same JSON as rendering the Markdown to HTML and importing it in Tiptap, except that:
112
+ It reads GitHub Flavored Markdown: tables, strikethrough, autolinks and task lists. Each Markdown node becomes the element commonmarker renders for it, and the schema's parse rules apply as in HTML import, so custom types can match Markdown too: a type that matches `img` also gets Markdown images. For the built-in types, it gives the same JSON as rendering the Markdown to HTML and importing it in Tiptap, except that:
112
113
 
113
114
  - code blocks lose their final newline, as in prosemirror-markdown;
114
- - task list items (`- [ ] todo`) stay as list items starting with `[ ] ` or `[x] `.
115
+ - a bullet list whose items all have a checkbox (`- [ ] todo`) is a task list. In other lists, which Tiptap's task lists can't hold, checkboxes stay as text at the start of their item: `[ ] ` or `[x] `.
115
116
 
116
117
  Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
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, strikethrough and task lists, 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, superscript and mentions 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, image sizes or mentions: by default, only their text or image is written, like `@Ada` for a mention. 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
+ - Details are written as `<details>` HTML, as on GitHub, with their content in Markdown.
135
+ - YouTube and Twitch embeds are written as links to the video. With `html: true`, they are written as their iframe.
136
+ - Empty paragraphs are left out.
137
+
138
+ 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).
139
+
120
140
  ## Text import
121
141
 
122
142
  ```ruby
@@ -130,8 +150,13 @@ Blank lines separate paragraphs, and other line breaks become hard breaks. Space
130
150
  Two schemas are included:
131
151
 
132
152
  ```ruby
133
- Prosemirror::Schema.tiptap # Tiptap's StarterKit, tables, images, underline, subscript, superscript, highlight (default)
134
- Prosemirror::Schema.prosemirror # prosemirror-schema-basic and prosemirror-schema-list
153
+ # The default: Tiptap's StarterKit, with tables, images, task lists,
154
+ # details, YouTube and Twitch embeds, mentions, underline, subscript,
155
+ # superscript and highlight
156
+ Prosemirror::Schema.tiptap
157
+
158
+ # prosemirror-schema-basic and prosemirror-schema-list
159
+ Prosemirror::Schema.prosemirror
135
160
  ```
136
161
 
137
162
  Type names are written in snake_case (`bullet_list`). Use `case: :camel` to write camelCase (`bulletList`), as Tiptap does. Both cases are always accepted on input.
@@ -140,6 +165,26 @@ Type names are written in snake_case (`bullet_list`). Use `case: :camel` to writ
140
165
  Prosemirror.schema = Prosemirror::Schema.tiptap(case: :camel)
141
166
  ```
142
167
 
168
+ `text_align: true` adds Tiptap's TextAlign extension, configured for headings and paragraphs: a `textAlign` attribute, `left`, `center`, `right` or `justify`, rendered as a `text-align` style. HTML import also reads the old `align` attribute. Markdown has no syntax for it: Markdown export leaves it out. Without the option, `textAlign` is kept in `to_h`, but not rendered.
169
+
170
+ ```ruby
171
+ Prosemirror::Schema.tiptap(text_align: true)
172
+ ```
173
+
174
+ YouTube and Twitch embeds store the video's URL in `src`, and render an iframe of its embed URL, built from the video's id: other URLs render nothing. HTML import reads Tiptap's markup, and also iframes of embed URLs without it. Twitch only plays embeds on the domain they name, so set it in a subclass, as Tiptap's `parent` option. YouTube's `nocookie` option is a subclass too:
175
+
176
+ ```ruby
177
+ class Twitch < Prosemirror::Nodes::Twitch
178
+ def parent = "example.com"
179
+ end
180
+
181
+ class Youtube < Prosemirror::Nodes::Youtube
182
+ def embed_host = "https://www.youtube-nocookie.com"
183
+ end
184
+
185
+ Prosemirror.schema = Prosemirror::Schema.tiptap.with(Twitch, Youtube)
186
+ ```
187
+
143
188
  ## Custom nodes and marks
144
189
 
145
190
  A node type is a Phlex component, and its attributes are Literal properties. The type name comes from the class name (`ImageBlock` → `image_block`).
data/ROADMAP.md CHANGED
@@ -192,14 +192,18 @@ 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
- ### 0.6: More Tiptap node types
202
+ ### 0.6: More Tiptap node types (released)
201
203
 
202
- - Task lists, text alignment, mentions, details, embeds.
204
+ - Mentions, task lists, details, and YouTube and Twitch embeds, in `Schema.tiptap`, with HTML that gives the same JSON as Tiptap's `generateJSON`.
205
+ - Text alignment, as an option: `Schema.tiptap(text_align: true)`.
206
+ - GitHub Flavored Markdown task lists in Markdown import and export. Details are written as GitHub's `<details>` HTML.
203
207
 
204
208
  ### 1.0
205
209
 
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:
@@ -216,3 +259,15 @@ Prosemirror.schema = Prosemirror::Schema.tiptap.with(Paragraph)
216
259
  ```
217
260
 
218
261
  A subclass that declares parse rules replaces the rules of its parent. See the [cookbook](COOKBOOK.md) for more examples.
262
+
263
+ With `Schema.tiptap(text_align: true)`, paragraphs and headings are `Prosemirror::TextAlign::Paragraph` and `Prosemirror::TextAlign::Heading`: subclass these to keep the alignment. Other types get it by including `Prosemirror::TextAlign`, which declares the `text_align` attribute, and rendering `text_align_style`:
264
+
265
+ ```ruby
266
+ class Callout < Prosemirror::NodeType
267
+ include Prosemirror::TextAlign
268
+ group :block
269
+ content "inline*"
270
+
271
+ def view_template(&) = aside(style: text_align_style, &)
272
+ end
273
+ ```
@@ -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?