prosemirror 1.0.0 → 1.1.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 +11 -0
- data/COOKBOOK.md +37 -15
- data/README.md +31 -1
- data/TYPES.md +3 -1
- data/lib/prosemirror/document.rb +106 -7
- data/lib/prosemirror/extensions/text_align.rb +1 -1
- data/lib/prosemirror/html_parser.rb +6 -1
- data/lib/prosemirror/html_renderer.rb +5 -2
- data/lib/prosemirror/json_parser.rb +4 -1
- data/lib/prosemirror/mark_type.rb +8 -0
- data/lib/prosemirror/markdown/builder.rb +2 -1
- data/lib/prosemirror/marks/link.rb +1 -0
- data/lib/prosemirror/marks/text_style.rb +51 -0
- data/lib/prosemirror/nodes/heading.rb +1 -1
- data/lib/prosemirror/nodes/table_cell.rb +2 -2
- data/lib/prosemirror/nodes/twitch.rb +4 -2
- data/lib/prosemirror/nodes/youtube.rb +4 -2
- data/lib/prosemirror/rendering.rb +7 -0
- data/lib/prosemirror/schema.rb +3 -1
- data/lib/prosemirror/transformer.rb +4 -2
- data/lib/prosemirror/version.rb +1 -1
- metadata +2 -2
- data/sig/prosemirror.rbs +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83299630ad74178efaf62c0e40968ef3e1a085ddba12d9b50684c05b1f27f057
|
|
4
|
+
data.tar.gz: fbb545591e530721443848ce7f2044aa62338437d2926d480f0baa0f5ef2ef3d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bdcd3ce77eed14f095685bd6d47d7d3d1d7ee0975f869845268487f4e91221dd22b721801ac91c7db3913222c638b1881e0396148bf266a3081155e905cfe011
|
|
7
|
+
data.tar.gz: af476e5844149a351f8fc4d6b3575cdc256ce23d4ca46c3e4c345ca10700c916f4a7dfd08b4769341e15ea19435c882a13e1bf1a6f50f3df4e7a1701ef478698
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.1.0] - 2026-10-26
|
|
4
|
+
|
|
5
|
+
- `Document#truncate(length, omission: "…")` gives a shorter document, for previews: about that many characters of text, cut at the end of a word, keeping the marks and blocks the text is in.
|
|
6
|
+
- `Document#headings` lists the headings, for a table of contents, with an id unique in the document, made from their text (with Rails' `parameterize` when ActiveSupport is loaded), and `to_html(heading_ids: true)` renders the same ids on them. A heading's own id is kept. The cookbook's heading anchors recipe becomes a table of contents recipe.
|
|
7
|
+
- Tiptap's TextStyle mark, `Prosemirror::Marks::TextStyle`, with the attributes of its TextStyleKit (`color`, `backgroundColor`, `fontFamily`, `fontSize`, `lineHeight`), rendered as a span's `style` and imported from it. As in Tiptap, it isn't in the default schema: add it with `Schema.tiptap.with(Prosemirror::Marks::TextStyle)`. Unsafe CSS values, like `url()`, are invalid.
|
|
8
|
+
- Mark types declare a `priority`, as in Tiptap: the schema orders marks by priority, then as listed, so a mark added with `with` takes its place among the others. Tiptap's link has 1000, and its TextStyle 101.
|
|
9
|
+
- `to_json` raised `JSON::NestingError` for documents nested more than about 50 levels deep, and HTML and Markdown import gave documents up to about 190 levels deep, more than `Prosemirror.parse` accepts. Import now flattens nodes from 90 levels deep, and `to_json` writes documents as deep as `parse` reads: 100 levels.
|
|
10
|
+
- Table cells render their `colwidth` as an attribute, as Tiptap does, so that HTML import gives it back: it was lost. An empty `colwidth` attribute imports as nil, as in Tiptap, rather than an empty array.
|
|
11
|
+
- Markdown import replaces U+0000 with U+FFFD, as the CommonMark spec requires. It was kept, and dropped once the document was rendered to HTML.
|
|
12
|
+
- HTML import reads Tiptap's YouTube and Twitch markup only around a YouTube or Twitch URL. With another URL, it gave an embed that rendered nothing.
|
|
13
|
+
|
|
3
14
|
## [1.0.0] - 2026-10-25
|
|
4
15
|
|
|
5
16
|
### Public API
|
data/COOKBOOK.md
CHANGED
|
@@ -65,30 +65,32 @@ end
|
|
|
65
65
|
|
|
66
66
|
Items with several paragraphs or a nested list, and items left empty, render as before.
|
|
67
67
|
|
|
68
|
-
##
|
|
68
|
+
## Table of contents
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
List the headings as links, and render the document with the same ids on its headings:
|
|
71
71
|
|
|
72
72
|
```ruby
|
|
73
|
-
class
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def view_template(&)
|
|
77
|
-
tag(:"h#{@level}", id: @id || slug, &)
|
|
73
|
+
class TableOfContents < Phlex::HTML
|
|
74
|
+
def initialize(document)
|
|
75
|
+
@document = document
|
|
78
76
|
end
|
|
79
77
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
78
|
+
def view_template
|
|
79
|
+
nav do
|
|
80
|
+
ul do
|
|
81
|
+
@document.headings.each do |heading|
|
|
82
|
+
li(class: "level-#{heading[:level]}") { a(href: "##{heading[:id]}") { heading[:text] } }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
87
86
|
end
|
|
88
87
|
end
|
|
88
|
+
|
|
89
|
+
render TableOfContents.new(doc)
|
|
90
|
+
render doc.to_phlex(heading_ids: true)
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
Ids are unique in the document: two headings with the same text get `setup` and `setup-2`.
|
|
92
94
|
|
|
93
95
|
## Quotes with their author
|
|
94
96
|
|
|
@@ -170,3 +172,23 @@ Prosemirror::Schema.tiptap(
|
|
|
170
172
|
end
|
|
171
173
|
)
|
|
172
174
|
```
|
|
175
|
+
|
|
176
|
+
## Word count
|
|
177
|
+
|
|
178
|
+
Count the words of a document from its text. A hyphenated word or an elision, like `well-known` or `l’été`, counts as one word, as in word processors:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
WORD = /[[:alnum:]]+(?:[-'’][[:alnum:]]+)*/
|
|
182
|
+
|
|
183
|
+
def word_count(document)
|
|
184
|
+
document.to_text.scan(WORD).size
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
To leave code blocks out of the count, remove them first:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
word_count(document.transform { |node| (node.type == :code_block) ? nil : node })
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Languages written without spaces between words, like Chinese or Japanese, need a segmenter instead.
|
data/README.md
CHANGED
|
@@ -53,6 +53,15 @@ doc.select { |node| node in { type: :heading, attrs: { level: 1 } } }
|
|
|
53
53
|
doc.content.first.text_content
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
`headings` lists the headings, for a table of contents, with an id unique in the document. `to_html(heading_ids: true)`, or `to_phlex(heading_ids: true)`, renders the same ids on the headings, so the links work:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
doc.headings # => [{level: 2, text: "Café & co", id: "cafe-co"}, {level: 3, text: "Setup", id: "setup"}]
|
|
60
|
+
doc.to_html(heading_ids: true) # => "<h2 id=\"cafe-co\">Café & co</h2>…"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Ids come from the heading's text, with a number when two are the same (`setup-2`). A heading's own id, an attribute its type declares or one the editor saved, like Tiptap's UniqueID extension, is kept.
|
|
64
|
+
|
|
56
65
|
## Transforming documents
|
|
57
66
|
|
|
58
67
|
`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:
|
|
@@ -79,6 +88,13 @@ end
|
|
|
79
88
|
doc.check # raises "doc.content[0]: invalid content for blockquote: paragraph text"
|
|
80
89
|
```
|
|
81
90
|
|
|
91
|
+
`truncate` gives a shorter document, for previews and listings: about that many characters of text, cut at the end of a word, followed by `omission` (`"…"` by default). It keeps the marks and blocks the text is in, and leaves out what comes after the cut. Trailing punctuation is dropped before the omission, and a block isn't started for less than one of its words. A document short enough is returned as it is.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
doc.truncate(200).to_html # => "<p>The first <strong>words</strong>…</p>"
|
|
95
|
+
doc.truncate(200, omission: " [more]").to_text
|
|
96
|
+
```
|
|
97
|
+
|
|
82
98
|
## HTML import
|
|
83
99
|
|
|
84
100
|
HTML import uses [Herb](https://herb-tools.dev)'s parser, which never raises on malformed HTML. Add it to your Gemfile:
|
|
@@ -132,7 +148,7 @@ doc.to_markdown(html: true) # underline, highlight, subscript, superscript and
|
|
|
132
148
|
```
|
|
133
149
|
|
|
134
150
|
- Text is escaped, so that it reads back as text: `*`, `_`, `[`, a `#` or `-` that would start a block, HTML-like `<b>`, and URLs that GitHub would turn into links.
|
|
135
|
-
- Markdown has no syntax for underline, highlight, subscript, superscript, image sizes or mentions: by default, only their text or image is written, like `@Ada` for a mention. With `html: true`, they are written as HTML.
|
|
151
|
+
- Markdown has no syntax for underline, highlight, subscript, superscript, text styles, image sizes or mentions: by default, only their text or image is written, like `@Ada` for a mention. With `html: true`, they are written as HTML.
|
|
136
152
|
- Emphasis that CommonMark can't write where it is, like bold on a comma between two letters, is dropped and its text kept.
|
|
137
153
|
- Tables are written as GFM tables. A cell's paragraphs and line breaks are joined with `<br>`, and its other blocks, like lists, are written as HTML.
|
|
138
154
|
- Details are written as `<details>` HTML, as on GitHub, with their content in Markdown.
|
|
@@ -195,6 +211,12 @@ strict = Prosemirror.schema.with_options(strict: true)
|
|
|
195
211
|
Prosemirror::Schema.tiptap(text_align: true)
|
|
196
212
|
```
|
|
197
213
|
|
|
214
|
+
Tiptap's TextStyle mark isn't in its StarterKit, nor in `Schema.tiptap`: add `Prosemirror::Marks::TextStyle`, with the attributes of its TextStyleKit: `color`, `backgroundColor`, `fontFamily`, `fontSize` and `lineHeight`, rendered as a span's `style`. HTML import reads them from spans' styles, merging nested spans as Tiptap does. Unlike Tiptap, a span without any of them gets no mark, and values that could load or run something, like `url()`, are invalid.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
Prosemirror::Schema.tiptap.with(Prosemirror::Marks::TextStyle)
|
|
218
|
+
```
|
|
219
|
+
|
|
198
220
|
YouTube and Twitch embeds store the video's URL in `src`, and render an iframe of its embed URL, built from the video's id: other URLs render nothing. HTML import reads Tiptap's markup, and also iframes of embed URLs without it. Twitch only plays embeds on the domain they name, so set it in a subclass, as Tiptap's `parent` option. YouTube's `nocookie` option is a subclass too:
|
|
199
221
|
|
|
200
222
|
```ruby
|
|
@@ -331,6 +353,14 @@ BUNDLE_GEMFILE=benchmark/Gemfile bundle install
|
|
|
331
353
|
BUNDLE_GEMFILE=benchmark/Gemfile bundle exec ruby benchmark/run.rb
|
|
332
354
|
```
|
|
333
355
|
|
|
356
|
+
`rake fuzz` imports random HTML and Markdown, made by mutating the test fixtures, for a minute. It reports inputs that raise, give an invalid document, or don't survive rendering and importing again, each shrunk to a small example to add as a fixture:
|
|
357
|
+
|
|
358
|
+
```sh
|
|
359
|
+
SECONDS=600 bundle exec rake fuzz # longer
|
|
360
|
+
SEED=1234 bundle exec rake fuzz # repeat a run
|
|
361
|
+
STRICT=1 bundle exec rake fuzz # a round trip must give the same document
|
|
362
|
+
```
|
|
363
|
+
|
|
334
364
|
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
335
365
|
|
|
336
366
|
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:
|
data/TYPES.md
CHANGED
|
@@ -60,8 +60,9 @@ Type names are snake_case Symbols in Ruby. On input, camelCase works too (`image
|
|
|
60
60
|
| `excludes "_"` | marks of the same type | The marks this one can't be combined with: names or groups, `"_"` for all. A link excludes other links, and Tiptap's `code` excludes all marks. |
|
|
61
61
|
| `spanning false` | true | Render one element per text node, instead of one for adjacent nodes with the same mark. |
|
|
62
62
|
| `code` | false | The mark holds code, as ProseMirror's `code`. Not used by this gem yet. |
|
|
63
|
+
| `priority 101` | 100 | The mark's place in the schema, as Tiptap's `priority`: higher first. Tiptap's link has 1000, and its TextStyle 101. |
|
|
63
64
|
|
|
64
|
-
The order of marks in the schema sorts the marks of a node, and nests their elements: the first mark is the outermost element.
|
|
65
|
+
The order of marks in the schema sorts the marks of a node, and nests their elements: the first mark is the outermost element. Marks are ordered by priority, then in the order they're listed and added with `with`, so a mark added with `with` takes the same place as in Tiptap when it declares Tiptap's priority.
|
|
65
66
|
|
|
66
67
|
## Content expressions
|
|
67
68
|
|
|
@@ -122,6 +123,7 @@ Without `view_template`, the content is rendered without a wrapping element. The
|
|
|
122
123
|
| `render_content(nodes)` | Renders any list of nodes, with their marks, for example only some of the node's children. |
|
|
123
124
|
| `safe_url(url)` | The URL after the schema's `resolve_url`, when its scheme is allowed, or nil. Use it for every URL from the document. |
|
|
124
125
|
| `prosemirror_schema` | The schema of the document being rendered. |
|
|
126
|
+
| `heading_id` | In a heading type, its id from `Document#headings`, when the document is rendered with `heading_ids: true`, or nil. A subclass of the built-in heading renders it with `id: heading_id`. |
|
|
125
127
|
|
|
126
128
|
Phlex escapes text and attribute values, but doesn't check URLs: pass them through `safe_url`.
|
|
127
129
|
|
data/lib/prosemirror/document.rb
CHANGED
|
@@ -33,17 +33,38 @@ module Prosemirror
|
|
|
33
33
|
end.to_a
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
# The
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
# The headings, for a table of contents, with their level, their text, and an id unique in the document:
|
|
37
|
+
#
|
|
38
|
+
# doc.headings # => [{level: 2, text: "Café & co", id: "cafe-co"}, {level: 2, text: "Café & co", id: "cafe-co-2"}]
|
|
39
|
+
#
|
|
40
|
+
# A heading's own `id`, an attribute its type declares or one the editor added, like Tiptap's UniqueID
|
|
41
|
+
# extension, is its id. Others get one from their text. `to_html(heading_ids: true)` renders the same ids.
|
|
42
|
+
def headings
|
|
43
|
+
heading_ids.map { |node, id| {level: node.attrs[:level], text: node.text_content, id:} }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The document as HTML, with `heading_ids: true` the ids of `headings` on the headings. The string is
|
|
47
|
+
# `html_safe` when ActiveSupport is loaded.
|
|
48
|
+
def to_html(heading_ids: false)
|
|
49
|
+
html = to_phlex(heading_ids:).call
|
|
39
50
|
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
# A Phlex component rendering the document, to render it inside another Phlex component:
|
|
43
54
|
#
|
|
44
55
|
# render doc.to_phlex
|
|
45
|
-
def to_phlex
|
|
46
|
-
HTMLRenderer.new(self)
|
|
56
|
+
def to_phlex(heading_ids: false)
|
|
57
|
+
HTMLRenderer.new(self, heading_ids:)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The id of each heading node, in order. Keyed by identity: two headings with the same text are equal.
|
|
61
|
+
# @api private
|
|
62
|
+
def heading_ids
|
|
63
|
+
nodes = select { |node| schema.node_type(node.type)&.<=(Nodes::Heading) }
|
|
64
|
+
taken = nodes.filter_map { |node| own_id(node) }.to_set
|
|
65
|
+
nodes.each_with_object({}.compare_by_identity) do |node, ids|
|
|
66
|
+
ids[node] = own_id(node) || unique_id(slug(node.text_content), taken)
|
|
67
|
+
end
|
|
47
68
|
end
|
|
48
69
|
|
|
49
70
|
# Lets Rails render the document in ERB templates and ViewComponents: `<%= render doc %>`.
|
|
@@ -71,8 +92,9 @@ module Prosemirror
|
|
|
71
92
|
to_h
|
|
72
93
|
end
|
|
73
94
|
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
# As deep as `Prosemirror.parse` accepts: the JSON gem's default limit allows about 50 nodes.
|
|
96
|
+
def to_json(*args)
|
|
97
|
+
args.empty? ? JSON.generate(to_h, max_nesting: JSONParser::MAX_NESTING) : to_h.to_json(*args)
|
|
76
98
|
end
|
|
77
99
|
|
|
78
100
|
def text_content
|
|
@@ -96,6 +118,31 @@ module Prosemirror
|
|
|
96
118
|
Document.new(Transformer.new(schema, &).call(root), schema: schema)
|
|
97
119
|
end
|
|
98
120
|
|
|
121
|
+
# A shorter document, for previews: about `length` characters of text, cut at the end of a word, with
|
|
122
|
+
# `omission` after it. It keeps its structure: the marks and blocks the text is in, and what comes before
|
|
123
|
+
# the cut. What comes after is left out. The document itself when its text is short enough.
|
|
124
|
+
#
|
|
125
|
+
# doc.truncate(200).to_html # => "<p>The first <strong>words</strong>…</p>"
|
|
126
|
+
def truncate(length, omission: "…")
|
|
127
|
+
cut_index, cut_text = cut(length)
|
|
128
|
+
return self unless cut_index
|
|
129
|
+
|
|
130
|
+
seen = 0
|
|
131
|
+
content = Transformer.new(schema, report: false) do |node|
|
|
132
|
+
if node.text?
|
|
133
|
+
seen += 1
|
|
134
|
+
if seen - 1 < cut_index then node
|
|
135
|
+
elsif seen - 1 == cut_index then node.with(text: cut_text + omission)
|
|
136
|
+
end
|
|
137
|
+
elsif seen > cut_index && (node.content.empty? || schema.node_type(node.type)&.leaf?)
|
|
138
|
+
nil
|
|
139
|
+
else
|
|
140
|
+
node
|
|
141
|
+
end
|
|
142
|
+
end.call(root)
|
|
143
|
+
Document.new(content, schema: schema)
|
|
144
|
+
end
|
|
145
|
+
|
|
99
146
|
# Raises InvalidDocument for the first problem with the schema: an unknown node or mark type, invalid
|
|
100
147
|
# attributes, content that doesn't match a content expression, or marks a node doesn't allow. Returns
|
|
101
148
|
# the document.
|
|
@@ -122,6 +169,58 @@ module Prosemirror
|
|
|
122
169
|
|
|
123
170
|
private
|
|
124
171
|
|
|
172
|
+
# Where to cut the text to keep about `length` characters, at the end of a word: the index of the last
|
|
173
|
+
# text node to keep, and its kept text, without trailing spaces and punctuation. Nil when the text is
|
|
174
|
+
# short enough. A first word longer than `length` is cut inside it.
|
|
175
|
+
def cut(length)
|
|
176
|
+
texts = select(&:text?)
|
|
177
|
+
remaining = length
|
|
178
|
+
texts.each_with_index do |node, index|
|
|
179
|
+
text = node.text
|
|
180
|
+
if text.length <= remaining
|
|
181
|
+
remaining -= text.length
|
|
182
|
+
next unless remaining.zero? && index < texts.size - 1
|
|
183
|
+
|
|
184
|
+
return [index, trim_end(text)]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
word_end = text[0, remaining + 1].rindex(/\s/)
|
|
188
|
+
kept = word_end ? trim_end(text[0, word_end]) : ""
|
|
189
|
+
return [index, kept] unless kept.empty?
|
|
190
|
+
return [index, text[0, remaining]] if index.zero?
|
|
191
|
+
|
|
192
|
+
return [index - 1, trim_end(texts[index - 1].text)]
|
|
193
|
+
end
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def trim_end(text) = text.sub(/[[:space:][:punct:]]+\z/, "")
|
|
198
|
+
|
|
199
|
+
def own_id(node)
|
|
200
|
+
id = node.attrs[:id] || node.unknown_attrs["id"]
|
|
201
|
+
id if id.is_a?(String) && !id.empty?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# "Café & co" becomes "cafe-co", with Rails' `parameterize` when ActiveSupport is loaded, so that the
|
|
205
|
+
# app's I18n transliteration rules apply. Text without letters or digits becomes "section".
|
|
206
|
+
def slug(text)
|
|
207
|
+
slug = if defined?(ActiveSupport::Inflector.parameterize)
|
|
208
|
+
ActiveSupport::Inflector.parameterize(text)
|
|
209
|
+
else
|
|
210
|
+
text.unicode_normalize(:nfkd).gsub(/\p{Mn}/, "").downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
|
|
211
|
+
end
|
|
212
|
+
slug.empty? ? "section" : slug
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The slug, or with a number after it when it's taken: "setup", "setup-2".
|
|
216
|
+
def unique_id(slug, taken)
|
|
217
|
+
id = slug
|
|
218
|
+
number = 1
|
|
219
|
+
id = "#{slug}-#{number += 1}" while taken.include?(id)
|
|
220
|
+
taken << id
|
|
221
|
+
id
|
|
222
|
+
end
|
|
223
|
+
|
|
125
224
|
def add_urls(urls, type, attrs)
|
|
126
225
|
type&.url_attributes&.each do |name|
|
|
127
226
|
value = attrs[name]
|
|
@@ -19,6 +19,11 @@ module Prosemirror
|
|
|
19
19
|
IGNORE_TAGS = %w[head noscript object script style title].to_set.freeze
|
|
20
20
|
LIST_TAGS = %w[ol ul].to_set.freeze
|
|
21
21
|
|
|
22
|
+
# How many nodes can be open before elements stop opening new ones, and are unwrapped like elements without
|
|
23
|
+
# a rule, so that documents aren't deeper than JSON documents can be. The rest leaves room for the nodes
|
|
24
|
+
# that wrap content where it doesn't fit, like a table's row and cell.
|
|
25
|
+
MAX_OPEN = JSONParser::MAX_DEPTH - 10
|
|
26
|
+
|
|
22
27
|
PRESERVE_WS = 1
|
|
23
28
|
PRESERVE_WS_FULL = 2
|
|
24
29
|
|
|
@@ -284,7 +289,7 @@ module Prosemirror
|
|
|
284
289
|
|
|
285
290
|
if rule ? rule.ignore : IGNORE_TAGS.include?(dom.name)
|
|
286
291
|
ignore_fallback(dom, marks)
|
|
287
|
-
elsif !rule || rule.skip || rule.close_parent
|
|
292
|
+
elsif !rule || rule.skip || rule.close_parent || (rule.node? && !match.type.leaf? && @nodes.size >= MAX_OPEN)
|
|
288
293
|
add_element_without_rule(dom, marks, rule, top)
|
|
289
294
|
else
|
|
290
295
|
inner_marks = read_styles(dom, marks)
|
|
@@ -7,15 +7,18 @@ module Prosemirror
|
|
|
7
7
|
class HTMLRenderer < Phlex::HTML
|
|
8
8
|
include Rendering
|
|
9
9
|
|
|
10
|
-
def initialize(document)
|
|
10
|
+
def initialize(document, heading_ids: false)
|
|
11
11
|
super()
|
|
12
12
|
@document = document
|
|
13
|
+
@heading_ids = heading_ids
|
|
13
14
|
@_prosemirror_schema = document.schema
|
|
14
15
|
end
|
|
15
16
|
|
|
16
|
-
# The document's URLs are resolved at once, with the schema's `resolve_urls`, for each rendering
|
|
17
|
+
# The document's URLs are resolved at once, with the schema's `resolve_urls`, for each rendering, and so
|
|
18
|
+
# are its heading ids, which need the whole document to be unique.
|
|
17
19
|
def view_template
|
|
18
20
|
@_prosemirror_urls = prosemirror_schema.resolve_urls(@document.urls) if prosemirror_schema.resolve_urls?
|
|
21
|
+
@_prosemirror_heading_ids = @document.heading_ids if @heading_ids
|
|
19
22
|
render_content([@document.root])
|
|
20
23
|
end
|
|
21
24
|
end
|
|
@@ -7,6 +7,9 @@ module Prosemirror
|
|
|
7
7
|
# @api private
|
|
8
8
|
class JSONParser
|
|
9
9
|
MAX_DEPTH = 100
|
|
10
|
+
# The JSON nesting of a document MAX_DEPTH deep: a node at that depth is an object nested 2 × MAX_DEPTH + 1
|
|
11
|
+
# deep, and its marks, a mark, the mark's attributes and an array in them add 4.
|
|
12
|
+
MAX_NESTING = (MAX_DEPTH * 2) + 5
|
|
10
13
|
EMPTY = [].freeze
|
|
11
14
|
|
|
12
15
|
def initialize(schema)
|
|
@@ -19,7 +22,7 @@ module Prosemirror
|
|
|
19
22
|
|
|
20
23
|
def parse(json)
|
|
21
24
|
# Frozen strings go into nodes without being copied.
|
|
22
|
-
hash = json.is_a?(String) ? JSON.parse(json, max_nesting:
|
|
25
|
+
hash = json.is_a?(String) ? JSON.parse(json, max_nesting: MAX_NESTING, freeze: true) : json
|
|
23
26
|
raise ArgumentError, "expected a Hash or a JSON String, got #{json.class}" unless hash.is_a?(Hash)
|
|
24
27
|
|
|
25
28
|
@path = ["doc"]
|
|
@@ -45,6 +45,14 @@ module Prosemirror
|
|
|
45
45
|
inherited_setting(:@group, [])
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# The mark's place in its schema, as Tiptap's `priority`: marks with a higher priority come first, and
|
|
49
|
+
# marks with the same priority keep the schema's order. Defaults to 100.
|
|
50
|
+
def priority(value = nil)
|
|
51
|
+
return @priority = value if value
|
|
52
|
+
|
|
53
|
+
inherited_setting(:@priority, 100)
|
|
54
|
+
end
|
|
55
|
+
|
|
48
56
|
# The mark's Markdown syntax, as prosemirror-markdown's MarkSerializerSpec:
|
|
49
57
|
# - `open` and `close`: strings, or blocks called with the MarkdownSerializer, the mark, the parent node
|
|
50
58
|
# and the index of the marked node;
|
|
@@ -13,6 +13,7 @@ module Prosemirror
|
|
|
13
13
|
# stays as text.
|
|
14
14
|
# - Code blocks lose their final newline, as in prosemirror-markdown.
|
|
15
15
|
# - YAML front matter, between `---` lines at the start, is dropped.
|
|
16
|
+
# - U+0000 is replaced with U+FFFD, as the CommonMark spec requires.
|
|
16
17
|
# @api private
|
|
17
18
|
class Builder
|
|
18
19
|
MAX_DEPTH = HTML::Builder::MAX_DEPTH
|
|
@@ -47,7 +48,7 @@ module Prosemirror
|
|
|
47
48
|
# A root Element holding the content.
|
|
48
49
|
def call(markdown)
|
|
49
50
|
self.class.load_commonmarker
|
|
50
|
-
markdown = UTF8.string(markdown)
|
|
51
|
+
markdown = UTF8.string(markdown).tr("\u0000", "\uFFFD")
|
|
51
52
|
document = parse(markdown)
|
|
52
53
|
return from_html(document) if raw_html?(document) && HTML::Builder.herb?
|
|
53
54
|
|
|
@@ -4,6 +4,7 @@ module Prosemirror
|
|
|
4
4
|
module Marks
|
|
5
5
|
# Tiptap's link. Without an allowed `href`, its text is rendered without a link.
|
|
6
6
|
class Link < MarkType
|
|
7
|
+
priority 1000 # As in Tiptap: the outermost mark.
|
|
7
8
|
prop :href, _String(length: 1..), url: true
|
|
8
9
|
prop :target, _String?
|
|
9
10
|
prop :rel, _String?
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
# Tiptap's TextStyle, with the attributes of its TextStyleKit: Color, BackgroundColor, FontFamily, FontSize
|
|
6
|
+
# and LineHeight, each rendered as a CSS property of a span. Not in `Schema.tiptap`, as in Tiptap's
|
|
7
|
+
# StarterKit: add it with `Schema.tiptap.with(Prosemirror::Marks::TextStyle)`.
|
|
8
|
+
#
|
|
9
|
+
# Unlike Tiptap, a span without any of these properties gets no mark, and a missing property is nil rather
|
|
10
|
+
# than "". Values that could load or run something, like `url()`, are invalid.
|
|
11
|
+
class TextStyle < MarkType
|
|
12
|
+
priority 101 # As in Tiptap: right after links.
|
|
13
|
+
# CSS values without `url()`, `expression()`, escapes or comments, nor `;` or braces that would end the
|
|
14
|
+
# declaration.
|
|
15
|
+
VALUE = %r{\A(?!.*(?:url\s*\(|expression\s*\(|\\|/\*))[\w\s#%.,'"()+-]+\z}im
|
|
16
|
+
# Property names, and the CSS property each one renders.
|
|
17
|
+
PROPERTIES = {
|
|
18
|
+
color: "color", background_color: "background-color", font_family: "font-family",
|
|
19
|
+
font_size: "font-size", line_height: "line-height"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
PROPERTIES.each do |name, property|
|
|
23
|
+
prop name, _String?(VALUE), json: Naming.camel(name), parse_html: ->(element) { TextStyle.style_of(element, property) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
parse_html(tag: "span[style]", consuming: false) do |element|
|
|
27
|
+
styled = PROPERTIES.each_value.any? { |property| style_of(element, property) }
|
|
28
|
+
styled ? {} : false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def view_template(&)
|
|
32
|
+
styles = PROPERTIES.filter_map do |name, property|
|
|
33
|
+
value = instance_variable_get(:"@#{name}")
|
|
34
|
+
"#{property}: #{value}" if value
|
|
35
|
+
end
|
|
36
|
+
styles.empty? ? yield : span(style: styles.join("; "), &)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The property in the span's style, or in the spans around it, whose styles Tiptap merges into the
|
|
40
|
+
# innermost one. Colors lose their quotes, as in Tiptap.
|
|
41
|
+
def self.style_of(element, property)
|
|
42
|
+
while element&.element? && element.name == "span"
|
|
43
|
+
value = element.style[property]
|
|
44
|
+
return property.end_with?("color") ? value.delete(%('")) : value if value && !value.empty?
|
|
45
|
+
|
|
46
|
+
element = element.parent
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -9,7 +9,7 @@ module Prosemirror
|
|
|
9
9
|
prop :level, _Integer(1..6), default: 1, &Coerce::INTEGER
|
|
10
10
|
(1..6).each { |level| parse_html tag: "h#{level}", attrs: {level:} }
|
|
11
11
|
|
|
12
|
-
def view_template(&) = tag(:"h#{@level}", &)
|
|
12
|
+
def view_template(&) = tag(:"h#{@level}", id: heading_id, &)
|
|
13
13
|
|
|
14
14
|
def to_markdown(markdown, _parent, _index)
|
|
15
15
|
markdown.write("#{"#" * @level} ")
|
|
@@ -10,7 +10,7 @@ module Prosemirror
|
|
|
10
10
|
prop :colspan, _Integer(1..), default: 1, &Coerce::INTEGER
|
|
11
11
|
prop :rowspan, _Integer(1..), default: 1, &Coerce::INTEGER
|
|
12
12
|
prop :colwidth, _Array?(_Integer(1..)),
|
|
13
|
-
parse_html: ->(element) { element["colwidth"]
|
|
13
|
+
parse_html: ->(element) { element["colwidth"].then { |value| value.split(",").map(&:to_i) unless value.to_s.empty? } }
|
|
14
14
|
# From the `text-align` style, or the old `align` attribute.
|
|
15
15
|
prop :align, _Union?(*ALIGNMENTS), parse_html: lambda { |element|
|
|
16
16
|
(element.style["text-align"] || element["align"]).to_s.strip.downcase.then { |value| value if ALIGNMENTS.include?(value) }
|
|
@@ -26,7 +26,7 @@ module Prosemirror
|
|
|
26
26
|
|
|
27
27
|
def cell_attributes
|
|
28
28
|
{colspan: (@colspan unless @colspan == 1), rowspan: (@rowspan unless @rowspan == 1),
|
|
29
|
-
style: @align && "text-align: #{@align}"}
|
|
29
|
+
colwidth: @colwidth&.join(","), style: @align && "text-align: #{@align}"}
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
end
|
|
@@ -20,9 +20,11 @@ module Prosemirror
|
|
|
20
20
|
prop :muted, _Boolean, default: false, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:muted) }
|
|
21
21
|
prop :time, _String?, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:time) }
|
|
22
22
|
|
|
23
|
-
# Tiptap's markup, `<div data-twitch-video><iframe>`, or an iframe of a Twitch embed URL.
|
|
23
|
+
# Tiptap's markup, `<div data-twitch-video><iframe>`, or an iframe of a Twitch embed URL. Not with a URL
|
|
24
|
+
# it can't render.
|
|
24
25
|
parse_html(tag: "iframe[src]") do |element|
|
|
25
|
-
|
|
26
|
+
src = element["src"]
|
|
27
|
+
(Twitch.from_embed_url(src) || (element.parent&.key?("data-twitch-video") && Twitch.identify(src))) ? {} : false
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
def view_template
|
|
@@ -20,9 +20,11 @@ module Prosemirror
|
|
|
20
20
|
prop :width, _Integer?(1..), default: 640, &Coerce::INTEGER
|
|
21
21
|
prop :height, _Integer?(1..), default: 480, &Coerce::INTEGER
|
|
22
22
|
|
|
23
|
-
# Tiptap's markup, `<div data-youtube-video><iframe>`, or an iframe of a YouTube embed URL.
|
|
23
|
+
# Tiptap's markup, `<div data-youtube-video><iframe>`, or an iframe of a YouTube embed URL. Not with a URL
|
|
24
|
+
# it can't render.
|
|
24
25
|
parse_html(tag: "iframe[src]") do |element|
|
|
25
|
-
|
|
26
|
+
src = element["src"]
|
|
27
|
+
(Youtube.from_embed_url(src) || (element.parent&.key?("data-youtube-video") && Youtube.embed_url(src))) ? {} : false
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
def view_template
|
|
@@ -19,6 +19,12 @@ module Prosemirror
|
|
|
19
19
|
prosemirror_schema.safe_url(url, @_prosemirror_urls)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# The heading's id from `Document#headings`, when the document is rendered with `heading_ids: true`.
|
|
23
|
+
# Otherwise nil.
|
|
24
|
+
def heading_id
|
|
25
|
+
@_prosemirror_heading_ids&.[](node)
|
|
26
|
+
end
|
|
27
|
+
|
|
22
28
|
private
|
|
23
29
|
|
|
24
30
|
def render_fragment(nodes, depth)
|
|
@@ -65,6 +71,7 @@ module Prosemirror
|
|
|
65
71
|
def prepare(component)
|
|
66
72
|
component.instance_variable_set(:@_prosemirror_schema, prosemirror_schema)
|
|
67
73
|
component.instance_variable_set(:@_prosemirror_urls, @_prosemirror_urls)
|
|
74
|
+
component.instance_variable_set(:@_prosemirror_heading_ids, @_prosemirror_heading_ids)
|
|
68
75
|
component
|
|
69
76
|
end
|
|
70
77
|
end
|
data/lib/prosemirror/schema.rb
CHANGED
|
@@ -39,7 +39,7 @@ module Prosemirror
|
|
|
39
39
|
Nodes::TaskItem, Nodes::Details, Nodes::DetailsSummary, Nodes::DetailsContent, Nodes::Youtube,
|
|
40
40
|
Nodes::Twitch, Nodes::Mention
|
|
41
41
|
],
|
|
42
|
-
# In Tiptap's order, which sorts and nests marks.
|
|
42
|
+
# In Tiptap's order, which sorts and nests marks: by priority, then as listed.
|
|
43
43
|
marks: [
|
|
44
44
|
Marks::Link, Marks::Bold, Marks::Code, Marks::Italic, Marks::Strike, Marks::Underline,
|
|
45
45
|
Marks::Subscript, Marks::Superscript, Marks::Highlight
|
|
@@ -62,6 +62,8 @@ module Prosemirror
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def after_initialize
|
|
65
|
+
# Marks sort and nest in this order, so a mark added with `with` takes its place by priority.
|
|
66
|
+
@marks = @marks.each_with_index.sort_by { |type, index| [-type.priority, index] }.map(&:first).freeze
|
|
65
67
|
@node_lookup = lookup(@nodes)
|
|
66
68
|
@mark_lookup = lookup(@marks)
|
|
67
69
|
@node_names = spellings(@node_lookup)
|
|
@@ -10,10 +10,12 @@ module Prosemirror
|
|
|
10
10
|
# Document#check.
|
|
11
11
|
# @api private
|
|
12
12
|
class Transformer
|
|
13
|
-
|
|
13
|
+
# With `report: false`, the required nodes are added without reporting them, when adding them is expected.
|
|
14
|
+
def initialize(schema, report: true, &block)
|
|
14
15
|
raise ArgumentError, "transform needs a block" unless block
|
|
15
16
|
|
|
16
17
|
@schema = schema
|
|
18
|
+
@report = report
|
|
17
19
|
@block = block
|
|
18
20
|
end
|
|
19
21
|
|
|
@@ -75,7 +77,7 @@ module Prosemirror
|
|
|
75
77
|
return nil if nodes.include?(nil)
|
|
76
78
|
|
|
77
79
|
names = types.map { |type| @schema.json_type_name(type.type_name) }.join(", ")
|
|
78
|
-
@schema.report("added required #{names} to #{@schema.json_type_name(node.type)}", path: path)
|
|
80
|
+
@schema.report("added required #{names} to #{@schema.json_type_name(node.type)}", path: path) if @report
|
|
79
81
|
nodes
|
|
80
82
|
end
|
|
81
83
|
end
|
data/lib/prosemirror/version.rb
CHANGED
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: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hans Lemuet
|
|
@@ -99,6 +99,7 @@ files:
|
|
|
99
99
|
- lib/prosemirror/marks/strike.rb
|
|
100
100
|
- lib/prosemirror/marks/subscript.rb
|
|
101
101
|
- lib/prosemirror/marks/superscript.rb
|
|
102
|
+
- lib/prosemirror/marks/text_style.rb
|
|
102
103
|
- lib/prosemirror/marks/underline.rb
|
|
103
104
|
- lib/prosemirror/naming.rb
|
|
104
105
|
- lib/prosemirror/node.rb
|
|
@@ -138,7 +139,6 @@ files:
|
|
|
138
139
|
- lib/prosemirror/type_definition.rb
|
|
139
140
|
- lib/prosemirror/utf8.rb
|
|
140
141
|
- lib/prosemirror/version.rb
|
|
141
|
-
- sig/prosemirror.rbs
|
|
142
142
|
homepage: https://github.com/etaminstudio/prosemirror
|
|
143
143
|
licenses:
|
|
144
144
|
- MIT
|
data/sig/prosemirror.rbs
DELETED