prosemirror 0.2.0 → 0.3.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 +6 -0
- data/README.md +40 -3
- data/ROADMAP.md +4 -4
- data/TYPES.md +17 -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/schema.rb +21 -0
- data/lib/prosemirror/utf8.rb +16 -0
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +12 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a11c83675790681267f7d5282882f967154892b408d8965e528b1c34d166876
|
|
4
|
+
data.tar.gz: 14ae8422a61cbe0be055d6ef7590a10981014430978089e14b2805ebf3100be2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 02ac22023a65ba9631752cf6c42b9895cfb4be99acc4d2b1922d0a9742e5d61c7d084455b0c6aec2f0c80400531870113b2ceba519f0a7a4af701de7e192c1f0
|
|
7
|
+
data.tar.gz: 8adae01f43c5ea57367db4d77605ceb28b4d606829b905440a716c64dc483c82a105898878349bb79f1fd73df4459651e9172d67546ee1d23968fd8cd3538801
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-10-24
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Import plain text with `Prosemirror.from_text` (or `schema.parse_text`): blank lines separate paragraphs, and other line breaks become hard breaks.
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
3
9
|
## [0.2.0] - 2026-10-23
|
|
4
10
|
|
|
5
11
|
- 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/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
|
|
|
@@ -70,6 +70,35 @@ It works like ProseMirror's `DOMParser`, and for the built-in types gives the sa
|
|
|
70
70
|
- entities are decoded, and ERB tags (`<%= … %>`) are kept as text;
|
|
71
71
|
- invalid attributes are fixed and reported, as in [Invalid documents](#invalid-documents).
|
|
72
72
|
|
|
73
|
+
## Markdown import
|
|
74
|
+
|
|
75
|
+
Markdown import uses [commonmarker](https://github.com/gjtorikian/commonmarker)'s parser, written in Rust. Add it to your Gemfile:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
bundle add commonmarker
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
doc = Prosemirror.from_markdown("## Hello\n\nSome **bold** text.")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
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:
|
|
86
|
+
|
|
87
|
+
- code blocks lose their final newline, as in prosemirror-markdown;
|
|
88
|
+
- task list items (`- [ ] todo`) stay as list items starting with `[ ] ` or `[x] `.
|
|
89
|
+
|
|
90
|
+
Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
|
|
91
|
+
|
|
92
|
+
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`.
|
|
93
|
+
|
|
94
|
+
## Text import
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
doc = Prosemirror.from_text("First line\nsecond line\n\nNew paragraph")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Blank lines separate paragraphs, and other line breaks become hard breaks. Spaces are kept.
|
|
101
|
+
|
|
73
102
|
## Schemas
|
|
74
103
|
|
|
75
104
|
Two schemas are included:
|
|
@@ -163,7 +192,15 @@ Prosemirror::Schema.tiptap(
|
|
|
163
192
|
|
|
164
193
|
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
194
|
|
|
166
|
-
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
195
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
196
|
+
|
|
197
|
+
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:
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
git tag v0.3.0 && git push origin v0.3.0
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
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
204
|
|
|
168
205
|
## Contributing
|
|
169
206
|
|
data/ROADMAP.md
CHANGED
|
@@ -169,11 +169,11 @@ 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
|
|
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:
|
|
@@ -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/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,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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hans Lemuet
|
|
@@ -82,6 +82,7 @@ files:
|
|
|
82
82
|
- lib/prosemirror/json_parser.rb
|
|
83
83
|
- lib/prosemirror/mark.rb
|
|
84
84
|
- lib/prosemirror/mark_type.rb
|
|
85
|
+
- lib/prosemirror/markdown/builder.rb
|
|
85
86
|
- lib/prosemirror/marks/basic/code.rb
|
|
86
87
|
- lib/prosemirror/marks/basic/em.rb
|
|
87
88
|
- lib/prosemirror/marks/basic/link.rb
|
|
@@ -122,6 +123,7 @@ files:
|
|
|
122
123
|
- lib/prosemirror/schema.rb
|
|
123
124
|
- lib/prosemirror/text_serializer.rb
|
|
124
125
|
- lib/prosemirror/type_definition.rb
|
|
126
|
+
- lib/prosemirror/utf8.rb
|
|
125
127
|
- lib/prosemirror/version.rb
|
|
126
128
|
- sig/prosemirror.rbs
|
|
127
129
|
homepage: https://github.com/etaminstudio/prosemirror
|