prosemirror 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1006b6a2ecb95f5a4d603d5d9321bbdab419f26f882365ff14d1692f27970ce7
4
- data.tar.gz: 22734518a2e81ecaba0ff64ed5057f40705dfb55dcd919567673f88e7cbf7e45
3
+ metadata.gz: c84fca02d8a8c1f32c9162da3a22133f2ef774c035281331d7d92423d24a242f
4
+ data.tar.gz: ca2ec139725747838a359f43a667c1fc4b5b5249b7dba12775ffbe39985f0c38
5
5
  SHA512:
6
- metadata.gz: 0f4b24b76e78e49523f068aa8520304c955428d4ff295c0e0f918dfad07ad0bc5b7ee391b8dacaeabf258f871d99f2dcfba15e4ebad4bec961e97529cdd24f34
7
- data.tar.gz: be218156ef3b417342de7d393d219b5019fe6f270a9c865e5cce9b5f9a2436008f59d5ada72d0111e09152586a06e21a7e98653ce4002923049eb0e70331daa0
6
+ metadata.gz: c018119a21927872e1382b1ee05f33c397781c87520b55ee4ca76f06abc2fb728ed4122043f11250cd3f8195c5ede8de4e50619bf98f93405f163fa30bd413aa
7
+ data.tar.gz: 3a870e5aceacd20c1ec717942f3dc4c75a97f811e8813e73c346aa71b919ab4430645593c6daec6132a1c72cd3f053e192ff812126cf22cd8f567cc5cafb8b98
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-10-24
4
+
5
+ - `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.
6
+ - `Document#check` raises `InvalidDocument` for the first problem with the schema, with its path, and `Document#valid?` returns true or false.
7
+ - `Node#blank?` is true for nodes that show nothing, like empty paragraphs.
8
+
9
+ ## [0.3.0] - 2026-10-24
10
+
11
+ - Import Markdown with `Prosemirror.from_markdown` (or `schema.parse_markdown`). Needs the [`commonmarker`](https://github.com/gjtorikian/commonmarker) gem, which isn't installed with this gem. GitHub Flavored Markdown tables, strikethrough and autolinks are supported, and the schema's parse rules apply, so custom types can match Markdown too. Raw HTML goes through HTML import when the herb gem is installed. YAML front matter is dropped.
12
+ - Import plain text with `Prosemirror.from_text` (or `schema.parse_text`): blank lines separate paragraphs, and other line breaks become hard breaks.
13
+ - HTML, Markdown and text import accept nil and binary strings, and replace invalid UTF-8 with U+FFFD, as browsers do. HTML import raised on invalid UTF-8.
14
+
3
15
  ## [0.2.0] - 2026-10-23
4
16
 
5
17
  - Import HTML with `Prosemirror.from_html` (or `schema.parse_html`), as ProseMirror's `DOMParser` does. Needs the [`herb`](https://herb-tools.dev) gem, which isn't installed with this gem.
data/COOKBOOK.md CHANGED
@@ -18,23 +18,35 @@ class Paragraph < Prosemirror::Nodes::Paragraph
18
18
  end
19
19
  ```
20
20
 
21
- A paragraph that only holds spaces or line breaks is not empty. To skip those too, check the text instead, keeping paragraphs with an inline image or another leaf node:
21
+ A paragraph that only holds spaces or line breaks is not empty. To skip those too, use `blank?`, which keeps paragraphs with an inline image or another leaf node:
22
22
 
23
23
  ```ruby
24
24
  class Paragraph < Prosemirror::Nodes::Paragraph
25
25
  def view_template(&)
26
- p(&) unless blank?
26
+ p(&) unless node.blank?(prosemirror_schema)
27
27
  end
28
+ end
29
+ ```
28
30
 
29
- private
31
+ `to_text` still separates empty paragraphs with a blank line, as ProseMirror does. `to_h` keeps them, so the editor gets the document back unchanged. To remove them from the document itself, see the next recipe.
30
32
 
31
- def blank?
32
- node.text_content.strip.empty? && node.content.all? { |child| child.text? || child.type == :hard_break }
33
+ ## Remove empty paragraphs before saving
34
+
35
+ To clean a document before saving it, remove blank paragraphs with `transform`, along with the list items and lists they leave empty:
36
+
37
+ ```ruby
38
+ schema = Prosemirror::Schema.tiptap(case: :camel) # saves `bulletList`, as Tiptap expects
39
+ document = Prosemirror.parse(params[:content], schema:).transform do |node|
40
+ case node
41
+ in { type: :paragraph | :list_item } if node.blank?(schema) then nil
42
+ in { type: :bullet_list | :ordered_list } if node.content.empty? then nil
43
+ else node
33
44
  end
34
45
  end
46
+ record.update!(content: document.to_h)
35
47
  ```
36
48
 
37
- `to_text` still separates empty paragraphs with a blank line, as ProseMirror does. `to_h` keeps them, so the editor gets the document back unchanged.
49
+ Without `case: :camel`, `to_h` writes snake_case type names like `bullet_list`, which Tiptap doesn't read. `blank?` gets the schema so that it knows your custom types: an image block isn't blank. Children are transformed first, so the list sees its remaining items. Attributes the schema doesn't declare are kept. When something the schema requires is gone, like the only paragraph of a list item kept by another rule, `transform` adds it back and reports it with `on_invalid`.
38
50
 
39
51
  ## Heading anchors
40
52
 
data/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # ProseMirror
2
2
 
3
- Render [ProseMirror](https://prosemirror.net) and [Tiptap](https://tiptap.dev) documents to HTML and text, and import HTML into them, in Ruby.
3
+ Render [ProseMirror](https://prosemirror.net) and [Tiptap](https://tiptap.dev) documents to HTML and text, and import HTML, Markdown and text into them, in Ruby.
4
4
 
5
5
  - HTML is built with [Phlex](https://www.phlex.fun) and escaped as it is written.
6
6
  - Attributes are typed with [Literal](https://literal.fun).
7
7
  - Link and image URLs must use an allowed scheme.
8
8
  - No Rails dependency.
9
9
 
10
- This gem replaces [prosemirror_to_html](https://github.com/etaminstudio/prosemirror_to_html) and [html_to_prosemirror](https://github.com/etaminstudio/html_to_prosemirror). Markdown import is planned: see the [roadmap](ROADMAP.md).
10
+ This gem replaces [prosemirror_to_html](https://github.com/etaminstudio/prosemirror_to_html) and [html_to_prosemirror](https://github.com/etaminstudio/html_to_prosemirror). See the [roadmap](ROADMAP.md) for what comes next.
11
11
 
12
12
  ## Installation
13
13
 
@@ -48,6 +48,32 @@ doc.select { |node| node in { type: :heading, attrs: { level: 1 } } }
48
48
  doc.content.first.text_content
49
49
  ```
50
50
 
51
+ ## Transforming documents
52
+
53
+ `transform` builds a new document from a block called with each node. The block returns the node to keep, another node, an array of nodes to put in its place, or nil to remove it:
54
+
55
+ ```ruby
56
+ doc = doc.transform do |node|
57
+ case node
58
+ in { type: :paragraph } if node.blank? then nil
59
+ in { type: :heading, attrs: { level: 1 } } then node.with(attrs: { level: 2 })
60
+ in { type: :bullet_list } then node.content.flat_map(&:content) # unwrap the list
61
+ else node
62
+ end
63
+ end
64
+ ```
65
+
66
+ - Children are transformed first: the block sees a node with its transformed content, and the nodes it returns aren't transformed again.
67
+ - Nodes are immutable: `with` returns a copy with some properties changed, like `content`, `attrs`, `marks` or `text`. Attributes the schema doesn't declare are kept.
68
+ - `blank?` is true for nodes that show nothing: no text but whitespace, and no leaf nodes but hard breaks.
69
+ - Adjacent text nodes with the same marks are joined. When a node's content changed and lacks what its type requires, like the only paragraph of a list item, the required nodes are added and reported with `on_invalid`.
70
+
71
+ `check` raises `Prosemirror::InvalidDocument` for the first problem with the schema, with its path: an unknown type, an invalid attribute, content that doesn't match a [content expression](TYPES.md#content-expressions), or marks a node doesn't allow. `valid?` returns true or false. Rendering doesn't need a valid document.
72
+
73
+ ```ruby
74
+ doc.check # raises "doc.content[0]: invalid content for blockquote: paragraph text"
75
+ ```
76
+
51
77
  ## HTML import
52
78
 
53
79
  HTML import uses [Herb](https://herb-tools.dev)'s parser, which never raises on malformed HTML. Add it to your Gemfile:
@@ -70,6 +96,35 @@ It works like ProseMirror's `DOMParser`, and for the built-in types gives the sa
70
96
  - entities are decoded, and ERB tags (`<%= … %>`) are kept as text;
71
97
  - invalid attributes are fixed and reported, as in [Invalid documents](#invalid-documents).
72
98
 
99
+ ## Markdown import
100
+
101
+ Markdown import uses [commonmarker](https://github.com/gjtorikian/commonmarker)'s parser, written in Rust. Add it to your Gemfile:
102
+
103
+ ```sh
104
+ bundle add commonmarker
105
+ ```
106
+
107
+ ```ruby
108
+ doc = Prosemirror.from_markdown("## Hello\n\nSome **bold** text.")
109
+ ```
110
+
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
+
113
+ - code blocks lose their final newline, as in prosemirror-markdown;
114
+ - task list items (`- [ ] todo`) stay as list items starting with `[ ] ` or `[x] `.
115
+
116
+ Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
117
+
118
+ 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
+ ## Text import
121
+
122
+ ```ruby
123
+ doc = Prosemirror.from_text("First line\nsecond line\n\nNew paragraph")
124
+ ```
125
+
126
+ Blank lines separate paragraphs, and other line breaks become hard breaks. Spaces are kept.
127
+
73
128
  ## Schemas
74
129
 
75
130
  Two schemas are included:
@@ -163,7 +218,15 @@ Prosemirror::Schema.tiptap(
163
218
 
164
219
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests, and `rake` to also run [Standard](https://github.com/standardrb/standard). You can also run `bin/console` for an interactive prompt that will allow you to experiment.
165
220
 
166
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
221
+ To install this gem onto your local machine, run `bundle exec rake install`.
222
+
223
+ To release a new version, update the version number in `version.rb` and the changelog, and merge them into main. Then tag the version and push the tag:
224
+
225
+ ```sh
226
+ git tag v0.3.0 && git push origin v0.3.0
227
+ ```
228
+
229
+ The [Push gem](.github/workflows/push_gem.yml) workflow publishes the gem to [rubygems.org](https://rubygems.org) with [trusted publishing](https://guides.rubygems.org/trusted-publishing/), after approval in the `release` environment.
167
230
 
168
231
  ## Contributing
169
232
 
data/ROADMAP.md CHANGED
@@ -169,15 +169,15 @@ They differ in more than names:
169
169
  - Old HTML mapped to the schema: `<b>` and `<i>` to `bold` and `italic`, `<strike>` to `strike`, `<tt>` to `code`. `<font>` and `<center>` are unwrapped.
170
170
  - Tests: HTML from Tiptap gives the same JSON as Tiptap.
171
171
 
172
- ### 0.3: Markdown and text import
172
+ ### 0.3: Markdown and text import (released)
173
173
 
174
- - `Prosemirror.from_markdown`, built from [`commonmarker`](https://github.com/gjtorikian/commonmarker)'s syntax tree, without going through HTML (like `prosemirror-markdown`). commonmarker's parser is written in Rust.
175
- - GitHub Flavored Markdown: tables, strikethrough, autolinks, task lists.
176
- - Custom node types can define their Markdown mapping.
174
+ - `Prosemirror.from_markdown`, built from [`commonmarker`](https://github.com/gjtorikian/commonmarker)'s syntax tree. commonmarker's parser is written in Rust. Each Markdown node becomes the element commonmarker renders for it, without going through an HTML string, and HTML import's parse rules build the document.
175
+ - GitHub Flavored Markdown: tables, strikethrough, autolinks. Task list items stay as text until task list nodes (0.6).
176
+ - Custom node types match Markdown with their parse rules.
177
177
  - Raw HTML in Markdown goes through the HTML importer if Herb is installed.
178
178
  - `Prosemirror.from_text`: blank lines make paragraphs, line breaks make hard breaks.
179
179
 
180
- ### 0.4: Working with documents
180
+ ### 0.4: Working with documents (released)
181
181
 
182
182
  - Transformations that return a new document: rewrite links, unwrap lists, split paragraphs, remove empty ones.
183
183
 
data/TYPES.md CHANGED
@@ -26,6 +26,7 @@ A type is a [Phlex](https://www.phlex.fun) component, and its attributes are [Li
26
26
  - [Attributes](#attributes)
27
27
  - [Rendering](#rendering)
28
28
  - [Parse rules](#parse-rules)
29
+ - [Markdown](#markdown)
29
30
  - [Subclassing built-in types](#subclassing-built-in-types)
30
31
 
31
32
  ## Names
@@ -186,6 +187,22 @@ The element passed to rules is a `Prosemirror::HTML::Element`, with lowercase na
186
187
  | `matches?("p.lead")` | Whether the element matches a selector. |
187
188
  | `parent` | The parent element. |
188
189
 
190
+ ## Markdown
191
+
192
+ Markdown import builds the elements commonmarker renders for each Markdown node, and applies the same parse rules. To match Markdown, a type matches these elements:
193
+
194
+ | Markdown | Element |
195
+ |---|---|
196
+ | Paragraph, heading, blockquote, thematic break | `p`, `h1`…`h6`, `blockquote`, `hr` |
197
+ | Bullet list, ordered list, list item | `ul`, `ol` (with `start` when it isn't 1), `li` |
198
+ | Code block | `pre` holding a `code` with a `language-…` class from the info string |
199
+ | Table | `table`, `tr`, `th` for the first row, `td`, with a `text-align` style for aligned columns |
200
+ | Emphasis, strong, strikethrough, inline code | `em`, `strong`, `s`, `code` |
201
+ | Link, image | `a` with `href` and `title`, `img` with `src`, `alt` and `title` |
202
+ | Hard line break | `br` |
203
+
204
+ Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
205
+
189
206
  ## Subclassing built-in types
190
207
 
191
208
  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:
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Checks a document against its schema, as ProseMirror's `Node#check`, and raises InvalidDocument for the
5
+ # first problem, with its path. See Document#check.
6
+ #
7
+ # Rendering and JSON parsing don't need valid content: they render what they get. This checks what
8
+ # they don't: node and mark types, attributes, content expressions, and the marks each node allows.
9
+ class Checker
10
+ def initialize(schema)
11
+ @schema = schema
12
+ end
13
+
14
+ def call(root)
15
+ name = @schema.json_type_name(root.type)
16
+ invalid("the top node is #{name}, not #{@schema.top_node}", name) unless root.type == @schema.top_node
17
+ check(root, name)
18
+ end
19
+
20
+ private
21
+
22
+ def check(node, path)
23
+ type = @schema.node_type(node.type) or invalid("unknown node type #{@schema.json_type_name(node.type)}", path)
24
+ return check_text(node, path) if node.text?
25
+
26
+ check_attrs(type, node.attrs, path)
27
+ node.content.each_with_index do |child, index|
28
+ @schema.node_type(child.type) or invalid("unknown node type #{@schema.json_type_name(child.type)}", "#{path}.content[#{index}]")
29
+ end
30
+ check_content(type, node, path)
31
+ node.content.each_with_index do |child, index|
32
+ child_path = "#{path}.content[#{index}]"
33
+ check_marks(type, child, child_path)
34
+ check(child, child_path)
35
+ end
36
+ end
37
+
38
+ def check_text(node, path)
39
+ invalid("empty text node", path) if node.text.to_s.empty?
40
+ invalid("text nodes have no content", path) unless node.content.empty?
41
+ end
42
+
43
+ def check_content(type, node, path)
44
+ if type.leaf?
45
+ invalid("#{name(type)} has no content, but has #{node.content.size} nodes", path) unless node.content.empty?
46
+ return
47
+ end
48
+
49
+ types = node.content.map { |child| @schema.node_type(child.type) }
50
+ return if @schema.content_match(type).match_types(types)&.valid_end?
51
+
52
+ found = node.content.map { |child| @schema.json_type_name(child.type) }.join(" ")
53
+ invalid("invalid content for #{name(type)}: #{found.empty? ? "nothing" : found}", path)
54
+ end
55
+
56
+ def check_marks(parent, node, path)
57
+ node.marks.each_with_index do |mark, index|
58
+ mark_type = @schema.mark_type(mark.type) or invalid("unknown mark type #{@schema.json_type_name(mark.type)}", "#{path}.marks[#{index}]")
59
+ check_attrs(mark_type, mark.attrs, "#{path}.marks[#{index}]")
60
+ invalid("#{name(parent)} doesn't allow #{name(mark_type)} marks", path) unless parent.allows_mark?(mark_type, @schema)
61
+ end
62
+ sorted = node.marks.reduce([]) { |set, mark| @schema.add_mark(mark, set) }
63
+ invalid("invalid marks: #{node.marks.map { |mark| @schema.json_type_name(mark.type) }.join(" ")}", path) unless sorted == node.marks
64
+ end
65
+
66
+ # Attributes are valid when building them from themselves reports nothing and changes nothing.
67
+ def check_attrs(type, attrs, path)
68
+ problems = []
69
+ built = type.build_attrs(attrs, report: ->(message) { problems << message })
70
+ invalid(problems.first, path) unless problems.empty?
71
+ invalid("invalid attributes: #{attrs.inspect}", path) unless built == attrs
72
+ end
73
+
74
+ def name(type)
75
+ @schema.json_type_name(type.type_name)
76
+ end
77
+
78
+ def invalid(message, path)
79
+ raise InvalidDocument.new(message, path: path)
80
+ end
81
+ end
82
+ end
@@ -64,6 +64,38 @@ module Prosemirror
64
64
  root.text_content
65
65
  end
66
66
 
67
+ # A new document built from a block called with each node, children first. The block returns the node
68
+ # to keep, another node, an array of nodes to put in its place, or nil to remove it:
69
+ #
70
+ # doc.transform do |node|
71
+ # case node
72
+ # in { type: :paragraph } if node.blank? then nil
73
+ # in { type: :heading, attrs: { level: 1 } } then node.with(attrs: { level: 2 })
74
+ # else node
75
+ # end
76
+ # end
77
+ #
78
+ # Adjacent text nodes with the same marks are joined. When a node's content changed and lacks what its
79
+ # content expression requires, the required nodes are added and reported with `on_invalid`.
80
+ def transform(&)
81
+ Document.new(Transformer.new(schema, &).call(root), schema: schema)
82
+ end
83
+
84
+ # Raises InvalidDocument for the first problem with the schema: an unknown node or mark type, invalid
85
+ # attributes, content that doesn't match a content expression, or marks a node doesn't allow. Returns
86
+ # the document.
87
+ def check
88
+ Checker.new(schema).call(root)
89
+ self
90
+ end
91
+
92
+ def valid?
93
+ check
94
+ true
95
+ rescue InvalidDocument
96
+ false
97
+ end
98
+
67
99
  def ==(other)
68
100
  other.is_a?(Document) && root == other.root
69
101
  end
@@ -22,11 +22,19 @@ module Prosemirror
22
22
  raise Error, 'HTML import needs the herb gem: add `gem "herb"` to your Gemfile'
23
23
  end
24
24
 
25
+ # Whether the herb gem is installed.
26
+ def self.herb?
27
+ load_herb
28
+ true
29
+ rescue Error
30
+ false
31
+ end
32
+
25
33
  # A root Element holding the parsed content.
26
34
  def call(html)
27
35
  self.class.load_herb
28
36
  root = Element.new("#document")
29
- Splitter.new(PIECE_SIZE).call(html.to_s).each do |piece|
37
+ Splitter.new(PIECE_SIZE).call(UTF8.string(html)).each do |piece|
30
38
  # Without `timeout: 0`, Herb stops after a second and returns what it parsed so far.
31
39
  build(Herb.parse(piece, track_locations: false, strict: false, timeout: 0).value.children, root)
32
40
  end
@@ -41,8 +41,13 @@ module Prosemirror
41
41
 
42
42
  # The document parsed from the HTML, as its top node.
43
43
  def parse(html)
44
+ parse_element(HTML::Builder.new.call(html))
45
+ end
46
+
47
+ # The document for an Element's content, like the one HTML::Builder gives.
48
+ def parse_element(root)
44
49
  context = Context.new(self)
45
- context.add_all(HTML::Builder.new.call(html), [])
50
+ context.add_all(root, [])
46
51
  context.finish
47
52
  end
48
53
 
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Markdown
5
+ # Builds the Elements and Texts of HTML import from Markdown, with commonmarker's syntax tree
6
+ # (https://github.com/gjtorikian/commonmarker), so that the schema's parse rules apply to Markdown too.
7
+ # Each Markdown node becomes the element commonmarker renders for it, without going through HTML.
8
+ #
9
+ # - GitHub Flavored Markdown: tables, strikethrough and autolinks. Task list items stay as text (`[ ] `).
10
+ # - Raw HTML can span several Markdown blocks, like `<del>` around a paragraph: with the herb gem, a
11
+ # document holding raw HTML is rendered to HTML by commonmarker and imported. Without it, raw HTML
12
+ # stays as text.
13
+ # - Code blocks lose their final newline, as in prosemirror-markdown.
14
+ # - YAML front matter, between `---` lines at the start, is dropped.
15
+ class Builder
16
+ MAX_DEPTH = HTML::Builder::MAX_DEPTH
17
+ EXTENSIONS = {
18
+ strikethrough: true, table: true, autolink: true, tasklist: false, shortcodes: false, header_ids: nil,
19
+ front_matter_delimiter: "---"
20
+ }.freeze
21
+ RENDER_OPTIONS = {extension: EXTENSIONS, render: {unsafe: true, hardbreaks: false, github_pre_lang: false}}.freeze
22
+ TAGS = {
23
+ paragraph: "p", block_quote: "blockquote", item: "li", thematic_break: "hr", table_row: "tr",
24
+ emph: "em", strong: "strong", strikethrough: "s", linebreak: "br"
25
+ }.freeze
26
+ # A table's delimiter row, like `| :-- | --: |`, at the end of its line.
27
+ DELIMITER_ROW = /\|?(?:\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\s*\z/
28
+ # The bytes commonmarker escapes in URLs, as comrak's `escape_href`: all but these, and `%` when it
29
+ # doesn't start an escape. `&` and `'` are kept: comrak writes them as HTML references.
30
+ URL_UNSAFE = %r{%(?!\h\h)|[^-_.+!*(),#@?=;:/$~A-Za-z0-9&'%]}n
31
+ # An IPv6 host at the start of a URL, like `http://[::1]`, kept as it is.
32
+ IPV6_HOST = %r{\A[A-Za-z][A-Za-z0-9+.-]*://\[[\h:.]+\]}
33
+
34
+ def self.load_commonmarker
35
+ require "commonmarker"
36
+ rescue LoadError
37
+ raise Error, 'Markdown import needs the commonmarker gem: add `gem "commonmarker"` to your Gemfile'
38
+ end
39
+
40
+ # A root Element holding the content.
41
+ def call(markdown)
42
+ self.class.load_commonmarker
43
+ markdown = UTF8.string(markdown)
44
+ document = parse(markdown)
45
+ return from_html(document) if raw_html?(document) && HTML::Builder.herb?
46
+
47
+ @lines = markdown.lines
48
+ root = HTML::Element.new("#document")
49
+ build(document, root)
50
+ root
51
+ end
52
+
53
+ private
54
+
55
+ def parse(markdown)
56
+ Commonmarker.parse(markdown, options: {extension: EXTENSIONS})
57
+ rescue Commonmarker::NestingDepthExceededError => error
58
+ raise Error, "Markdown nested too deeply: #{error.message}"
59
+ end
60
+
61
+ # Walks the tree without recursion. Each item of the stack is a node, the element to add it to, and
62
+ # the depth of that element. Elements nested deeper than MAX_DEPTH are flattened.
63
+ def build(document, root)
64
+ stack = document.to_a.reverse.map { |node| [node, root, 0] }
65
+ until stack.empty?
66
+ node, target, depth = stack.pop
67
+ element = add(node, target)
68
+ next unless element
69
+
70
+ if depth >= MAX_DEPTH
71
+ element = target
72
+ else
73
+ target.append(element)
74
+ end
75
+ node.to_a.reverse_each { |child| stack << [child, element, element.equal?(target) ? depth : depth + 1] }
76
+ end
77
+ end
78
+
79
+ # Adds a node to the target. Returns the element to add its children to, when they need adding.
80
+ def add(node, target)
81
+ case node.type
82
+ when *TAGS.keys then HTML::Element.new(TAGS[node.type])
83
+ when :heading then HTML::Element.new("h#{node.header_level}")
84
+ when :list then list(node)
85
+ when :code_block then code_block(node, target)
86
+ when :table then table(node)
87
+ when :table_cell then table_cell(node)
88
+ when :link then HTML::Element.new("a", {"href" => escape_url(node.url), "title" => presence(node.title)}.compact)
89
+ when :image then image(node, target)
90
+ when :code then text(target, node.string_content, "code")
91
+ when :text then text(target, node.string_content)
92
+ when :softbreak then text(target, "\n")
93
+ when :html_block then text(target.append(HTML::Element.new("p")), node.literal.chomp)
94
+ when :html_inline then text(target, node.literal)
95
+ when :frontmatter then nil
96
+ else HTML::Element.new("span") # Unwrapped by HTML import, like escaped characters (`\*`).
97
+ end
98
+ end
99
+
100
+ def list(node)
101
+ return HTML::Element.new("ul") unless node.list_type == :ordered
102
+
103
+ HTML::Element.new("ol", (node.list_start == 1) ? {} : {"start" => node.list_start.to_s})
104
+ end
105
+
106
+ def code_block(node, target)
107
+ language = node.fence_info.to_s.split.first
108
+ code = HTML::Element.new("code", language ? {"class" => "language-#{language}"} : {})
109
+ code.append(HTML::Text.new(node.string_content.chomp))
110
+ target.append(HTML::Element.new("pre")).append(code)
111
+ nil
112
+ end
113
+
114
+ def table(node)
115
+ @alignments = alignments(node)
116
+ @row = 0
117
+ HTML::Element.new("table")
118
+ end
119
+
120
+ # The first row holds header cells.
121
+ def table_cell(node)
122
+ index = cell_index(node)
123
+ @row += 1 if index.zero?
124
+ align = @alignments[index]
125
+ HTML::Element.new((@row == 1) ? "th" : "td", align ? {"style" => "text-align: #{align}"} : {})
126
+ end
127
+
128
+ def cell_index(node)
129
+ index = 0
130
+ index += 1 while (node = node.previous_sibling)
131
+ index
132
+ end
133
+
134
+ # commonmarker doesn't give the alignments of a table's columns: they are read from its delimiter row,
135
+ # the line after its first line.
136
+ def alignments(node)
137
+ row = @lines[node.source_position[:start_line]].to_s.chomp[DELIMITER_ROW].to_s
138
+ row.strip.delete_prefix("|").delete_suffix("|").split("|").map do |cell|
139
+ cell = cell.strip
140
+ if cell.start_with?(":") && cell.end_with?(":") then "center"
141
+ elsif cell.end_with?(":") then "right"
142
+ elsif cell.start_with?(":") then "left"
143
+ end
144
+ end
145
+ end
146
+
147
+ def image(node, target)
148
+ attributes = {"src" => escape_url(node.url), "alt" => plain_text(node), "title" => presence(node.title)}.compact
149
+ target.append(HTML::Element.new("img", attributes))
150
+ nil
151
+ end
152
+
153
+ def text(target, value, tag = nil)
154
+ target = target.append(HTML::Element.new(tag)) if tag
155
+ target.append(HTML::Text.new(value))
156
+ nil
157
+ end
158
+
159
+ def raw_html?(document)
160
+ document.walk { |node| return true if node.type == :html_block || node.type == :html_inline }
161
+ false
162
+ end
163
+
164
+ def from_html(document)
165
+ root = HTML::Builder.new.call(document.to_html(options: RENDER_OPTIONS, plugins: {syntax_highlighter: nil}))
166
+ chomp_code_blocks(root)
167
+ root
168
+ end
169
+
170
+ # The code blocks commonmarker renders, `<pre><code>`, not the `<pre>` elements of raw HTML.
171
+ def chomp_code_blocks(element)
172
+ element.children.each do |child|
173
+ next unless child.element?
174
+ next chomp_code_blocks(child) unless child.name == "pre"
175
+
176
+ code = child.children.first
177
+ next unless code&.element? && code.name == "code" && child.children.size == 1
178
+
179
+ last = code.children.last
180
+ code.children[-1] = HTML::Text.new(last.value.chomp).tap { |text| text.parent = code } if last.is_a?(HTML::Text)
181
+ end
182
+ end
183
+
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
190
+
191
+ def plain_text(node)
192
+ node.to_a.map { |child| %i[text code].include?(child.type) ? child.string_content : plain_text(child) }.join
193
+ end
194
+
195
+ def presence(value)
196
+ value unless value.nil? || value.empty?
197
+ end
198
+ end
199
+ end
200
+ end
@@ -11,6 +11,9 @@ module Prosemirror
11
11
  # in JSON (for example from a Tiptap extension the schema doesn't know): they are never rendered,
12
12
  # and `to_h` gives them back unchanged.
13
13
  class Node < Literal::Data
14
+ # Whitespace, including non-breaking spaces.
15
+ BLANK = /\A[[:space:]]*\z/
16
+
14
17
  prop :type, Symbol, &Coerce::SYMBOL
15
18
  prop :attrs, _Hash(Symbol, _Any?), default: {}.freeze, &Coerce::FROZEN
16
19
  prop :unknown_attrs, _Hash(String, _Any?), default: {}.freeze, &Coerce::FROZEN
@@ -70,6 +73,18 @@ module Prosemirror
70
73
  end
71
74
  end
72
75
 
76
+ # Whether the node shows nothing: no text but whitespace, and no leaf nodes but line breaks, like an
77
+ # empty paragraph, or a list item holding one. The schema says which nodes are leaves: an image isn't
78
+ # blank, and neither is a node of a type it doesn't know.
79
+ def blank?(schema = Prosemirror.schema)
80
+ [self, *descendants].none? do |node|
81
+ next !BLANK.match?(node.text) if node.text?
82
+
83
+ type = schema.node_type(node.type)
84
+ type.nil? || (type.leaf? && !type.linebreak_replacement?)
85
+ end
86
+ end
87
+
73
88
  def same_markup?(other)
74
89
  @type == other.type && @attrs == other.attrs && @unknown_attrs == other.unknown_attrs && @marks == other.marks
75
90
  end
@@ -117,6 +117,27 @@ module Prosemirror
117
117
  Document.new(HTMLParser.new(self).parse(html), schema: self)
118
118
  end
119
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
+
120
141
  # The type name as written in JSON.
121
142
  def json_type_name(name)
122
143
  (@case == :camel) ? Naming.camel(name) : name.to_s
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Builds a new document from a block called with each node, children first. See Document#transform.
5
+ #
6
+ # The block returns the node to keep, another node, an array of nodes to put in its place, or nil to
7
+ # remove it. Adjacent text nodes with the same marks are then joined, and empty ones removed. When a
8
+ # node's content changed and no longer has what its content expression requires, the required nodes
9
+ # are added, as an empty paragraph in an empty list item, and reported. Other problems are left for
10
+ # Document#check.
11
+ class Transformer
12
+ def initialize(schema, &block)
13
+ raise ArgumentError, "transform needs a block" unless block
14
+
15
+ @schema = schema
16
+ @block = block
17
+ end
18
+
19
+ # The root, with its transformed content. The root itself isn't given to the block.
20
+ def call(root)
21
+ path = @schema.json_type_name(root.type)
22
+ filled(root, root.with(content: transform_content(root, path)), path)
23
+ end
24
+
25
+ private
26
+
27
+ def transform_content(node, path)
28
+ content = node.content.each_with_index.flat_map { |child, index| transform(child, "#{path}.content[#{index}]") }
29
+ Node.join_text(content.reject { |child| child.text? && child.text.empty? })
30
+ end
31
+
32
+ # The nodes the block gives for a node, after its content, with their required content added: the block
33
+ # sees an emptied list item, and can remove it.
34
+ def transform(node, path)
35
+ changed = node.content.empty? ? node : node.with(content: transform_content(node, path))
36
+ nodes = case (result = @block.call(changed))
37
+ when nil then []
38
+ when Node then [result]
39
+ when Array then result
40
+ end
41
+ raise ArgumentError, "the transform block returns a node, an array of nodes or nil, not #{result.inspect}" unless nodes&.all?(Node)
42
+
43
+ nodes.map { |result_node| filled(node, result_node, path) }
44
+ end
45
+
46
+ # The node with its required content added, when its content changed.
47
+ def filled(original, node, path)
48
+ return node if node.content.equal?(original.content) || node.content == original.content
49
+
50
+ content = fill(node, node.content, path)
51
+ content.equal?(node.content) ? node : node.with(content: content)
52
+ end
53
+
54
+ # The content with the nodes its type requires added in front of it, or after it, when they can be.
55
+ def fill(node, content, path)
56
+ type = @schema.node_type(node.type)
57
+ types = content.map { |child| @schema.node_type(child.type) }
58
+ return content if type.nil? || type.leaf? || types.include?(nil)
59
+
60
+ match = @schema.content_match(type)
61
+ return content if match.match_types(types)&.valid_end?
62
+
63
+ if (before = match.fill_before(types, @schema, to_end: true))
64
+ added = created(before, node, path) and return added + content
65
+ elsif (after = match.match_types(types)&.fill_before([], @schema, to_end: true))
66
+ added = created(after, node, path) and return content + added
67
+ end
68
+ content
69
+ end
70
+
71
+ # Empty nodes of these types, filled with what they require, or nil when one can't be created.
72
+ def created(types, node, path)
73
+ nodes = types.map { |type| @schema.create_and_fill(type) }
74
+ return nil if nodes.include?(nil)
75
+
76
+ names = types.map { |type| @schema.json_type_name(type.type_name) }.join(", ")
77
+ @schema.report("added required #{names} to #{@schema.json_type_name(node.type)}", path: path)
78
+ nodes
79
+ end
80
+ end
81
+ end
@@ -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.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/prosemirror.rb CHANGED
@@ -10,7 +10,8 @@ require "zeitwerk"
10
10
 
11
11
  loader = Zeitwerk::Loader.for_gem
12
12
  loader.inflector.inflect(
13
- "html" => "HTML", "html_parser" => "HTMLParser", "html_renderer" => "HTMLRenderer", "json_parser" => "JSONParser"
13
+ "html" => "HTML", "html_parser" => "HTMLParser", "html_renderer" => "HTMLRenderer", "json_parser" => "JSONParser",
14
+ "utf8" => "UTF8"
14
15
  )
15
16
  loader.setup
16
17
 
@@ -45,5 +46,15 @@ module Prosemirror
45
46
  def from_html(html, schema: self.schema)
46
47
  schema.parse_html(html)
47
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
48
59
  end
49
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans Lemuet
@@ -66,6 +66,7 @@ files:
66
66
  - ROADMAP.md
67
67
  - TYPES.md
68
68
  - lib/prosemirror.rb
69
+ - lib/prosemirror/checker.rb
69
70
  - lib/prosemirror/coerce.rb
70
71
  - lib/prosemirror/content_expression.rb
71
72
  - lib/prosemirror/content_match.rb
@@ -82,6 +83,7 @@ files:
82
83
  - lib/prosemirror/json_parser.rb
83
84
  - lib/prosemirror/mark.rb
84
85
  - lib/prosemirror/mark_type.rb
86
+ - lib/prosemirror/markdown/builder.rb
85
87
  - lib/prosemirror/marks/basic/code.rb
86
88
  - lib/prosemirror/marks/basic/em.rb
87
89
  - lib/prosemirror/marks/basic/link.rb
@@ -121,7 +123,9 @@ files:
121
123
  - lib/prosemirror/rendering.rb
122
124
  - lib/prosemirror/schema.rb
123
125
  - lib/prosemirror/text_serializer.rb
126
+ - lib/prosemirror/transformer.rb
124
127
  - lib/prosemirror/type_definition.rb
128
+ - lib/prosemirror/utf8.rb
125
129
  - lib/prosemirror/version.rb
126
130
  - sig/prosemirror.rbs
127
131
  homepage: https://github.com/etaminstudio/prosemirror