tiptap-ruby 0.9.18 → 0.10.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: 0f80fc490b18b20992c6187a203bf0a95c15877fdf3fcf03a8251044c0f291a3
4
- data.tar.gz: da90c78641f97b707c73c4cabb7417751e3a4464d86ad0559fb0e754fcec1cac
3
+ metadata.gz: 2745d7dc0e8cc41e8be3aca7a626fe0c20205ab6331768efe30d43a7dba967cd
4
+ data.tar.gz: cd62d869e49297848d9c27a647edc9992b78d55b3941ce04b73a930dbeb3fbc5
5
5
  SHA512:
6
- metadata.gz: 5fa946d32ebd379a5ff23445b984fcd8fae6573156864d4c0542278af90f08dea4631b8f37ad34510b40ed70c68b9998ab125bb58a8f8290d50a1a8867760999
7
- data.tar.gz: 4790cb500b05a7cb3fa900d8a7d73998511a148b6940d6e8ccb53aaeaa2f88bf2520c9ece3b3fb82e9e3844562741390cc91971323e10e8d818a66d0c606d4c7
6
+ metadata.gz: c49c2d3449cac786dcc90531dcfb25c010af8918302db6b6733fdcca8cc5ad52b0fd462684032405172647a9168e787ade66716074f7f74afeac74e249cc4ae2
7
+ data.tar.gz: 959a97bfe7556e49a36acf26a8a09a60338fc41d57bd1ead6d7111144d19735908e8f1af851709f383371ebf2cab6a1fcd8428954188044a3efbe60294723135
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.10.0] - 2025-09-19
4
+
5
+ - Add support for rendering documents to markdown
6
+ - Update rexml for CVE
7
+
3
8
  ## [0.9.16] - 2025-04-22
4
9
 
5
10
  - Update Nokogiri for CVE
data/README.md CHANGED
@@ -68,6 +68,14 @@ document.to_h # => { type: 'doc', content: […nodes]}
68
68
  document.to_html # => <div class="tiptap-document"><h1><em>My Important Document</em></h1></div>
69
69
  ```
70
70
 
71
+ ### Markdown
72
+
73
+ Generate GitHub-flavored Markdown that preserves nested lists, code blocks, tables, and inline marks.
74
+
75
+ ```ruby
76
+ document.to_markdown # => "# My Important Document\n\n- Item one\n- Item two"
77
+ ```
78
+
71
79
  ### Plain Text
72
80
 
73
81
  Rendering to plain text is useful if you want to search the contents of your TipTap content.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TipTap
4
+ module Markdown
5
+ # Rendering context for markdown serialization. Tracks indentation
6
+ # for nested blocks and whether we are currently inside a fenced
7
+ # code block so nodes can adjust formatting.
8
+ class Context
9
+ attr_reader :indent, :within_code_block
10
+
11
+ def initialize(indent: 0, within_code_block: false)
12
+ @indent = indent
13
+ @within_code_block = within_code_block
14
+ end
15
+
16
+ def self.root
17
+ new
18
+ end
19
+
20
+ def indentation
21
+ " " * indent
22
+ end
23
+
24
+ def increase_indent(amount)
25
+ self.class.new(indent: indent + amount, within_code_block: within_code_block)
26
+ end
27
+
28
+ def with_code_block
29
+ self.class.new(indent: indent, within_code_block: true)
30
+ end
31
+
32
+ def within_code_block?
33
+ within_code_block
34
+ end
35
+ end
36
+ end
37
+
38
+ module MarkdownRenderable
39
+ def to_markdown(context = Markdown::Context.root)
40
+ rendered_blocks = content.map { |node| node.to_markdown(context) }.reject(&:blank?)
41
+ rendered_blocks.join("\n\n").gsub(/\n{3,}/, "\n\n").rstrip
42
+ end
43
+ end
44
+ end
data/lib/tip_tap/node.rb CHANGED
@@ -4,6 +4,7 @@ require "tip_tap/registerable"
4
4
  require "tip_tap/html_renderable"
5
5
  require "tip_tap/json_renderable"
6
6
  require "tip_tap/plain_text_renderable"
7
+ require "tip_tap/markdown_renderable"
7
8
  require "tip_tap/has_content"
8
9
 
9
10
  # This is the base class for all TipTap nodes.
@@ -16,5 +17,6 @@ module TipTap
16
17
  include HtmlRenderable
17
18
  include JsonRenderable
18
19
  include PlainTextRenderable
20
+ include MarkdownRenderable
19
21
  end
20
22
  end
@@ -14,6 +14,15 @@ module TipTap
14
14
 
15
15
  add_content(Paragraph.new(&block))
16
16
  end
17
+
18
+ def to_markdown(context = Markdown::Context.root)
19
+ inner = super(context)
20
+ lines = inner.split("\n", -1)
21
+ quoted = lines.map do |line|
22
+ line.strip.empty? ? ">" : "> #{line}"
23
+ end
24
+ quoted.join("\n")
25
+ end
17
26
  end
18
27
  end
19
28
  end
@@ -14,6 +14,10 @@ module TipTap
14
14
 
15
15
  add_content(ListItem.new(&block))
16
16
  end
17
+
18
+ def to_markdown(context = Markdown::Context.root)
19
+ content.map { |node| node.to_markdown(context, marker: "- ") }.join("\n")
20
+ end
17
21
  end
18
22
  end
19
23
  end
@@ -11,6 +11,15 @@ module TipTap
11
11
  def code(text)
12
12
  add_content(Text.new(text, marks: [{type: "code"}]))
13
13
  end
14
+
15
+ def to_markdown(context = Markdown::Context.root)
16
+ fence_language = attrs["language"].presence || attrs["lang"].presence
17
+ fence_header = "```#{fence_language}"
18
+ code_context = context.with_code_block
19
+ body = content.map { |node| node.to_markdown(code_context) }.join
20
+ body = "#{body}\n" unless body.end_with?("\n") || body.empty?
21
+ "#{fence_header}\n#{body}```"
22
+ end
14
23
  end
15
24
  end
16
25
  end
@@ -14,6 +14,10 @@ module TipTap
14
14
  def to_html
15
15
  tag.br
16
16
  end
17
+
18
+ def to_markdown(context = Markdown::Context.root)
19
+ " \n"
20
+ end
17
21
  end
18
22
  end
19
23
  end
@@ -15,6 +15,13 @@ module TipTap
15
15
  def level
16
16
  attrs["level"]
17
17
  end
18
+
19
+ def to_markdown(context = Markdown::Context.root)
20
+ heading_level = [level.to_i, 1].max
21
+ prefix = "#" * heading_level
22
+ body = content.map { |node| node.to_markdown(context) }.join.strip
23
+ body.empty? ? prefix : "#{prefix} #{body}"
24
+ end
18
25
  end
19
26
  end
20
27
  end
@@ -14,6 +14,10 @@ module TipTap
14
14
  def to_html
15
15
  tag.hr
16
16
  end
17
+
18
+ def to_markdown(context = Markdown::Context.root)
19
+ "---"
20
+ end
17
21
  end
18
22
  end
19
23
  end
@@ -15,6 +15,15 @@ module TipTap
15
15
  image_tag(src, alt: alt)
16
16
  end
17
17
 
18
+ def to_markdown(context = Markdown::Context.root)
19
+ return "" if src.blank?
20
+
21
+ alt_text = alt.to_s
22
+ escaped_alt = alt_text.gsub("[", "\\[").gsub("]", "\\]")
23
+ escaped_src = src.to_s.gsub("(", "\\(").gsub(")", "\\)")
24
+ "![#{escaped_alt}](#{escaped_src})"
25
+ end
26
+
18
27
  def alt
19
28
  attrs["alt"]
20
29
  end
@@ -14,6 +14,79 @@ module TipTap
14
14
 
15
15
  add_content(Paragraph.new(&block))
16
16
  end
17
+
18
+ def bullet_list(&block)
19
+ raise ArgumentError, "Block required" if block.nil?
20
+
21
+ add_content(BulletList.new(&block))
22
+ end
23
+
24
+ def ordered_list(&block)
25
+ raise ArgumentError, "Block required" if block.nil?
26
+
27
+ add_content(OrderedList.new(&block))
28
+ end
29
+
30
+ def task_list(&block)
31
+ raise ArgumentError, "Block required" if block.nil?
32
+
33
+ add_content(TaskList.new(&block))
34
+ end
35
+
36
+ def to_markdown(context = Markdown::Context.root, marker: "- ")
37
+ marker_length = marker.length
38
+ first_prefix = context.indentation + marker
39
+ rest_prefix = " " * (context.indent + marker_length)
40
+ child_context = context.increase_indent(marker_length)
41
+
42
+ segments = content.map do |node|
43
+ if list_node?(node)
44
+ {type: :list, text: node.to_markdown(child_context)}
45
+ elsif paragraph_node?(node)
46
+ {type: :inline, text: node.to_markdown(child_context)}
47
+ else
48
+ {type: :block, text: node.to_markdown(child_context)}
49
+ end
50
+ end
51
+
52
+ first_segment = segments.shift || {type: :inline, text: ""}
53
+ first_text = (first_segment[:type] == :list) ? "" : first_segment[:text]
54
+ result = format_block(first_text, first_prefix, rest_prefix)
55
+
56
+ segments.each do |segment|
57
+ case segment[:type]
58
+ when :list
59
+ result << "\n" unless result.end_with?("\n")
60
+ result << segment[:text]
61
+ else
62
+ result << "\n\n"
63
+ result << format_block(segment[:text], rest_prefix, rest_prefix)
64
+ end
65
+ end
66
+
67
+ result
68
+ end
69
+
70
+ private
71
+
72
+ def paragraph_node?(node)
73
+ node.is_a?(Paragraph)
74
+ end
75
+
76
+ def list_node?(node)
77
+ node.is_a?(BulletList) || node.is_a?(OrderedList) || node.is_a?(TaskList)
78
+ end
79
+
80
+ def format_block(text, first_prefix, rest_prefix)
81
+ lines = text.to_s.split("\n", -1)
82
+ return first_prefix if lines.empty?
83
+
84
+ formatted = "#{first_prefix}#{lines.first}"
85
+ lines[1..]&.each do |line|
86
+ formatted << "\n#{rest_prefix}#{line}"
87
+ end
88
+ formatted
89
+ end
17
90
  end
18
91
  end
19
92
  end
@@ -18,6 +18,16 @@ module TipTap
18
18
  def start
19
19
  attrs["start"]
20
20
  end
21
+
22
+ def to_markdown(context = Markdown::Context.root)
23
+ starting_index = start.to_i
24
+ starting_index = 1 if starting_index <= 0
25
+
26
+ content.each_with_index.map do |node, index|
27
+ marker = "#{starting_index + index}. "
28
+ node.to_markdown(context, marker: marker)
29
+ end.join("\n")
30
+ end
21
31
  end
22
32
  end
23
33
  end
@@ -12,6 +12,10 @@ module TipTap
12
12
  add_content(Text.new(text, marks: marks))
13
13
  end
14
14
 
15
+ def to_markdown(context = Markdown::Context.root)
16
+ content.map { |node| node.to_markdown(context) }.join
17
+ end
18
+
15
19
  # Override the default to_plain_text method to account for the nested Text nodes
16
20
  # we don't want to use the separator when joining the text nodes since it could
17
21
  # be a newline or some other character that we don't want to include in the plain text
@@ -13,6 +13,41 @@ module TipTap
13
13
 
14
14
  add_content(TableRow.new(&block))
15
15
  end
16
+
17
+ def to_markdown(context = Markdown::Context.root)
18
+ rows_data = content.map { |row| row.to_markdown_row(context) }
19
+ return "" if rows_data.empty?
20
+
21
+ column_count = rows_data.map { |row| row[:cells].size }.max || 0
22
+ return "" if column_count.zero?
23
+
24
+ header_index = rows_data.index { |row| row[:is_header] }
25
+ header_cells = if header_index
26
+ rows_data.delete_at(header_index)[:cells]
27
+ else
28
+ Array.new(column_count, "")
29
+ end
30
+
31
+ header_cells = pad_cells(header_cells, column_count)
32
+ separator_cells = Array.new(column_count, "---")
33
+ body_rows = rows_data.map { |row| pad_cells(row[:cells], column_count) }
34
+
35
+ lines = []
36
+ lines << format_row(header_cells)
37
+ lines << format_row(separator_cells)
38
+ body_rows.each { |cells| lines << format_row(cells) }
39
+ lines.join("\n")
40
+ end
41
+
42
+ private
43
+
44
+ def pad_cells(cells, desired_size)
45
+ Array.new(desired_size) { |index| cells[index] || "" }
46
+ end
47
+
48
+ def format_row(cells)
49
+ "| #{cells.join(" | ")} |"
50
+ end
16
51
  end
17
52
  end
18
53
  end
@@ -13,6 +13,12 @@ module TipTap
13
13
 
14
14
  add_content(Paragraph.new(&block))
15
15
  end
16
+
17
+ def to_markdown(context = Markdown::Context.root)
18
+ values = content.map { |node| node.to_markdown(context) }.reject(&:blank?)
19
+ joined = values.join("<br>")
20
+ joined.gsub("\n\n", "<br><br>").gsub("\n", "<br>")
21
+ end
16
22
  end
17
23
  end
18
24
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tip_tap/node"
3
+ require "tip_tap/nodes/table_cell"
4
4
 
5
5
  module TipTap
6
6
  module Nodes
7
- class TableHeader < Node
7
+ class TableHeader < TableCell
8
8
  self.type_name = "tableHeader"
9
9
  self.html_tag = :th
10
10
 
@@ -19,6 +19,18 @@ module TipTap
19
19
 
20
20
  add_content(TableHeader.new(&block))
21
21
  end
22
+
23
+ def to_markdown(context = Markdown::Context.root)
24
+ row_data = to_markdown_row(context)
25
+ "| #{row_data[:cells].join(" | ")} |"
26
+ end
27
+
28
+ def to_markdown_row(context)
29
+ {
30
+ cells: content.map { |node| node.to_markdown(context) },
31
+ is_header: content.all? { |node| node.is_a?(TableHeader) }
32
+ }
33
+ end
22
34
  end
23
35
  end
24
36
  end
@@ -1,23 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tip_tap/node"
3
+ require "tip_tap/nodes/list_item"
4
4
 
5
5
  module TipTap
6
6
  module Nodes
7
- class TaskItem < Node
7
+ class TaskItem < ListItem
8
8
  self.type_name = "taskItem"
9
9
  self.html_tag = :li
10
10
  self.html_class_name = proc { class_names("task-item", {checked: checked?}) }
11
11
 
12
- def paragraph(&block)
13
- raise ArgumentError, "Block required" if block.nil?
14
-
15
- add_content(Paragraph.new(&block))
16
- end
17
-
18
12
  def checked?
19
13
  attrs["checked"]
20
14
  end
15
+
16
+ def to_markdown(context = Markdown::Context.root)
17
+ marker = checked? ? "- [x] " : "- [ ] "
18
+ super(context, marker: marker)
19
+ end
21
20
  end
22
21
  end
23
22
  end
@@ -14,6 +14,10 @@ module TipTap
14
14
 
15
15
  add_content(TaskItem.new(checked: checked, &block))
16
16
  end
17
+
18
+ def to_markdown(context = Markdown::Context.root)
19
+ content.map { |node| node.to_markdown(context) }.join("\n")
20
+ end
17
21
  end
18
22
  end
19
23
  end
@@ -48,6 +48,27 @@ module TipTap
48
48
  text
49
49
  end
50
50
 
51
+ def to_markdown(context = Markdown::Context.root)
52
+ value = text.to_s
53
+ return "" if value.empty?
54
+
55
+ return value if context.within_code_block?
56
+
57
+ return wrap_with_backticks(value) if code?
58
+
59
+ value = escape_markdown(value)
60
+ value = apply_bold(value) if bold?
61
+ value = apply_italic(value) if italic?
62
+ value = apply_strike(value) if strike?
63
+ value = wrap_with_html_tag("u", value) if underline?
64
+ value = apply_highlight(value) if highlight?
65
+ value = apply_text_style(value) if text_style?
66
+ value = wrap_with_html_tag("sup", value) if superscript?
67
+ value = wrap_with_html_tag("sub", value) if subscript?
68
+ value = wrap_with_link(value) if link?
69
+ value
70
+ end
71
+
51
72
  def italic?
52
73
  has_mark_with_type?("italic")
53
74
  end
@@ -125,6 +146,83 @@ module TipTap
125
146
  end
126
147
  content_tag(:mark, text, data: data, style: inline_style_content(styles))
127
148
  end
149
+
150
+ def escape_markdown(value)
151
+ value.gsub(/([\\`*_{}\[\]()#+!><~-])/) { |char| "\\#{char}" }
152
+ end
153
+
154
+ def wrap_with_backticks(value)
155
+ max_tick_sequence = value.scan(/`+/).map(&:length).max || 0
156
+ wrapper = "`" * (max_tick_sequence + 1)
157
+ "#{wrapper}#{value}#{wrapper}"
158
+ end
159
+
160
+ def apply_bold(value)
161
+ "**#{value}**"
162
+ end
163
+
164
+ def apply_italic(value)
165
+ "_#{value}_"
166
+ end
167
+
168
+ def apply_strike(value)
169
+ "~~#{value}~~"
170
+ end
171
+
172
+ def apply_highlight(value)
173
+ attributes = {}
174
+ styles = {}
175
+ if highlight_color
176
+ attributes["data-color"] = highlight_color
177
+ styles["background-color"] = highlight_color
178
+ styles["color"] = "inherit"
179
+ end
180
+ wrap_with_html_tag("mark", value, attributes, styles)
181
+ end
182
+
183
+ def apply_text_style(value)
184
+ styles = text_styles || {}
185
+ return value if styles.empty?
186
+
187
+ wrap_with_html_tag("span", value, {}, styles)
188
+ end
189
+
190
+ def wrap_with_link(value)
191
+ href = link_href.to_s
192
+ return value if href.blank?
193
+
194
+ destination = escape_link_destination(href)
195
+ title = link_title
196
+ title_part = title.present? ? " \"#{escape_double_quotes(title)}\"" : ""
197
+ "[#{value}](#{destination}#{title_part})"
198
+ end
199
+
200
+ def wrap_with_html_tag(tag, value, attributes = {}, styles = {})
201
+ attr_segments = []
202
+ attributes.each do |key, attr_value|
203
+ next if attr_value.blank?
204
+ attr_segments << "#{key}=\"#{escape_double_quotes(attr_value)}\""
205
+ end
206
+ style_segment = inline_style_content(styles)
207
+ attr_segments << "style=\"#{escape_double_quotes(style_segment)}\"" if style_segment.present?
208
+ attributes_string = attr_segments.empty? ? "" : " #{attr_segments.join(" ")}"
209
+ "<#{tag}#{attributes_string}>#{value}</#{tag}>"
210
+ end
211
+
212
+ def escape_link_destination(href)
213
+ escaped = href.gsub("(", "\\(").gsub(")", "\\)")
214
+ escaped.gsub(/[\s]/) do |char|
215
+ (char == " ") ? "%20" : char
216
+ end
217
+ end
218
+
219
+ def escape_double_quotes(value)
220
+ value.to_s.gsub('"', "&quot;")
221
+ end
222
+
223
+ def link_title
224
+ marks.find { |mark| mark["type"] == "link" }&.dig("attrs", "title")
225
+ end
128
226
  end
129
227
  end
130
228
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TipTap
4
- VERSION = "0.9.18"
4
+ VERSION = "0.10.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tiptap-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.18
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chad Wilken
@@ -53,6 +53,7 @@ files:
53
53
  - lib/tip_tap/has_content.rb
54
54
  - lib/tip_tap/html_renderable.rb
55
55
  - lib/tip_tap/json_renderable.rb
56
+ - lib/tip_tap/markdown_renderable.rb
56
57
  - lib/tip_tap/node.rb
57
58
  - lib/tip_tap/nodes/blockquote.rb
58
59
  - lib/tip_tap/nodes/bullet_list.rb