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 +4 -4
- data/CHANGELOG.md +12 -0
- data/COOKBOOK.md +18 -6
- data/README.md +66 -3
- data/ROADMAP.md +5 -5
- data/TYPES.md +17 -0
- data/lib/prosemirror/checker.rb +82 -0
- data/lib/prosemirror/document.rb +32 -0
- data/lib/prosemirror/html/builder.rb +9 -1
- data/lib/prosemirror/html_parser.rb +6 -1
- data/lib/prosemirror/markdown/builder.rb +200 -0
- data/lib/prosemirror/node.rb +15 -0
- data/lib/prosemirror/schema.rb +21 -0
- data/lib/prosemirror/transformer.rb +81 -0
- data/lib/prosemirror/utf8.rb +16 -0
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +12 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c84fca02d8a8c1f32c9162da3a22133f2ef774c035281331d7d92423d24a242f
|
|
4
|
+
data.tar.gz: ca2ec139725747838a359f43a667c1fc4b5b5249b7dba12775ffbe39985f0c38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
`
|
|
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).
|
|
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`.
|
|
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
|
|
175
|
-
- GitHub Flavored Markdown: tables, strikethrough, autolinks
|
|
176
|
-
- Custom node types
|
|
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
|
data/lib/prosemirror/document.rb
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
data/lib/prosemirror/node.rb
CHANGED
|
@@ -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
|
data/lib/prosemirror/schema.rb
CHANGED
|
@@ -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
|
data/lib/prosemirror/version.rb
CHANGED
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.
|
|
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
|