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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c411a333e40240328c4e19d8b5f8870c0f90bad21573b6fc68b503901d2f0b7
4
- data.tar.gz: d13dcd8a9fb6001183010cc73c82a6541d6466214fcf59947d01bb5a4e2c0c21
3
+ metadata.gz: 83299630ad74178efaf62c0e40968ef3e1a085ddba12d9b50684c05b1f27f057
4
+ data.tar.gz: fbb545591e530721443848ce7f2044aa62338437d2926d480f0baa0f5ef2ef3d
5
5
  SHA512:
6
- metadata.gz: '0311783f814484b6f3e682c07228590084a97f9a1e555bdc9dbbb61acfd5d88c3ae4688c2b6afada4f1683395df878e0c88f97e4f673dfe106250e017f21d532'
7
- data.tar.gz: 35b83befa7e82312b910152784800192d958151497436abba2d279b8d80929430a7062fe3c003f5ec84c3b713a0b8c12693412767fd863f51588d5ba625a3af1
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
- ## Heading anchors
68
+ ## Table of contents
69
69
 
70
- To link to a section, give each heading an `id` made from its text, unless the document already has one:
70
+ List the headings as links, and render the document with the same ids on its headings:
71
71
 
72
72
  ```ruby
73
- class Heading < Prosemirror::Nodes::Heading
74
- prop :id, _String?
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
- private
81
-
82
- # "Café & co" becomes "cafe-co". In Rails, `node.text_content.parameterize` does the same.
83
- def slug
84
- slug = node.text_content.unicode_normalize(:nfkd).gsub(/\p{Mn}/, "").downcase.gsub(/[^a-z0-9]+/, "-")
85
- slug = slug.delete_prefix("-").delete_suffix("-")
86
- slug unless slug.empty?
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
- A node doesn't see the rest of the document, so two headings with the same text get the same `id`.
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é &amp; 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
 
@@ -33,17 +33,38 @@ module Prosemirror
33
33
  end.to_a
34
34
  end
35
35
 
36
- # The document as HTML. The string is `html_safe` when ActiveSupport is loaded.
37
- def to_html
38
- html = to_phlex.call
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
- def to_json(*)
75
- to_h.to_json(*)
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]
@@ -27,7 +27,7 @@ module Prosemirror
27
27
  class Heading < Nodes::Heading
28
28
  include TextAlign
29
29
 
30
- def view_template(&) = tag(:"h#{@level}", style: text_align_style, &)
30
+ def view_template(&) = tag(:"h#{@level}", id: heading_id, style: text_align_style, &)
31
31
  end
32
32
  end
33
33
  end
@@ -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: (MAX_DEPTH * 2) + 2, freeze: true) : json
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"]&.split(",")&.map(&:to_i) }
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
- (element.parent&.key?("data-twitch-video") || Twitch.from_embed_url(element["src"])) ? {} : false
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
- (element.parent&.key?("data-youtube-video") || Youtube.from_embed_url(element["src"])) ? {} : false
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
@@ -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
- def initialize(schema, &block)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prosemirror
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  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: 1.0.0
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
@@ -1,4 +0,0 @@
1
- module Prosemirror
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end