panda-editor 0.4.0 → 0.6.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.
@@ -5,19 +5,19 @@ module Panda
5
5
  module Blocks
6
6
  class Image < Base
7
7
  def render
8
- url = data["url"]
9
- caption = sanitize(data["caption"])
10
- with_border = data["withBorder"]
11
- with_background = data["withBackground"]
12
- stretched = data["stretched"]
8
+ url = data['url']
9
+ caption = sanitize(data['caption'])
10
+ with_border = data['withBorder']
11
+ with_background = data['withBackground']
12
+ stretched = data['stretched']
13
13
 
14
- css_classes = ["prose"]
15
- css_classes << "border" if with_border
16
- css_classes << "bg-gray-100" if with_background
17
- css_classes << "w-full" if stretched
14
+ css_classes = ['prose']
15
+ css_classes << 'border' if with_border
16
+ css_classes << 'bg-gray-100' if with_background
17
+ css_classes << 'w-full' if stretched
18
18
 
19
19
  html_safe(<<~HTML)
20
- <figure class="#{css_classes.join(" ")}">
20
+ <figure class="#{css_classes.join(' ')}">
21
21
  <img src="#{url}" alt="#{caption}" />
22
22
  #{caption_element(caption)}
23
23
  </figure>
@@ -27,7 +27,7 @@ module Panda
27
27
  private
28
28
 
29
29
  def caption_element(caption)
30
- return "" if caption.blank?
30
+ return '' if caption.blank?
31
31
 
32
32
  "<figcaption>#{caption}</figcaption>"
33
33
  end
@@ -5,10 +5,10 @@ module Panda
5
5
  module Blocks
6
6
  class List < Base
7
7
  def render
8
- list_type = (data["style"] == "ordered") ? "ol" : "ul"
8
+ list_type = data['style'] == 'ordered' ? 'ol' : 'ul'
9
9
  html_safe(
10
10
  "<#{list_type}>" \
11
- "#{render_items(data["items"])}" \
11
+ "#{render_items(data['items'])}" \
12
12
  "</#{list_type}>"
13
13
  )
14
14
  end
@@ -17,14 +17,14 @@ module Panda
17
17
 
18
18
  def render_items(items)
19
19
  items.map do |item|
20
- content = item.is_a?(Hash) ? item["content"] : item
21
- nested = (item.is_a?(Hash) && item["items"].present?) ? render_nested(item["items"]) : ""
20
+ content = item.is_a?(Hash) ? item['content'] : item
21
+ nested = item.is_a?(Hash) && item['items'].present? ? render_nested(item['items']) : ''
22
22
  "<li>#{sanitize(content)}#{nested}</li>"
23
23
  end.join
24
24
  end
25
25
 
26
26
  def render_nested(items)
27
- self.class.new({"items" => items, "style" => data["style"]}).render
27
+ self.class.new({ 'items' => items, 'style' => data['style'] }).render
28
28
  end
29
29
  end
30
30
  end
@@ -5,10 +5,10 @@ module Panda
5
5
  module Blocks
6
6
  class Paragraph < Base
7
7
  def render
8
- content = sanitize(data["text"])
9
- return "" if content.blank?
8
+ content = sanitize(data['text'])
9
+ return '' if content.blank?
10
10
 
11
- content = inject_footnotes(content) if data["footnotes"].present?
11
+ content = inject_footnotes(content) if data['footnotes'].present?
12
12
 
13
13
  html_safe("<p>#{content}</p>")
14
14
  end
@@ -16,15 +16,15 @@ module Panda
16
16
  private
17
17
 
18
18
  def inject_footnotes(text)
19
- return text unless data["footnotes"].is_a?(Array)
19
+ return text unless data['footnotes'].is_a?(Array)
20
20
 
21
21
  # Sort footnotes by position in descending order to avoid position shifts
22
- footnotes = data["footnotes"].sort_by { |fn| -fn["position"].to_i }
22
+ footnotes = data['footnotes'].sort_by { |fn| -fn['position'].to_i }
23
23
 
24
24
  footnotes.each do |footnote|
25
- position = footnote["position"].to_i
25
+ position = footnote['position'].to_i
26
26
  # Skip if position is beyond text length
27
- next if position < 0 || position > text.length
27
+ next if position.negative? || position > text.length
28
28
 
29
29
  # Register footnote with renderer's footnote registry
30
30
  footnote_number = register_footnote(footnote)
@@ -34,7 +34,7 @@ module Panda
34
34
  marker = "<sup id=\"fnref:#{footnote_number}\"><a href=\"#fn:#{footnote_number}\" class=\"footnote\">#{footnote_number}</a></sup>"
35
35
 
36
36
  # Insert marker at position
37
- text = text.insert(position, marker)
37
+ text.insert(position, marker)
38
38
  end
39
39
 
40
40
  text
@@ -44,8 +44,8 @@ module Panda
44
44
  return nil unless options[:footnote_registry]
45
45
 
46
46
  options[:footnote_registry].add(
47
- id: footnote["id"],
48
- content: footnote["content"]
47
+ id: footnote['id'],
48
+ content: footnote['content']
49
49
  )
50
50
  end
51
51
  end
@@ -5,15 +5,15 @@ module Panda
5
5
  module Blocks
6
6
  class Quote < Base
7
7
  def render
8
- text = data["text"]
9
- caption = data["caption"]
10
- alignment = data["alignment"] || "left"
8
+ text = data['text']
9
+ caption = data['caption']
10
+ alignment = data['alignment'] || 'left'
11
11
 
12
12
  # Build the HTML structure
13
13
  html = "<figure class=\"text-#{alignment}\">" \
14
14
  "<blockquote>#{wrap_text_in_p(text)}</blockquote>" \
15
15
  "#{caption_element(caption)}" \
16
- "</figure>"
16
+ '</figure>'
17
17
 
18
18
  # Return raw HTML - validation will be handled by the main renderer if enabled
19
19
  html_safe(html)
@@ -24,7 +24,7 @@ module Panda
24
24
  def wrap_text_in_p(text)
25
25
  # Only wrap in <p> if it's not already wrapped
26
26
  text = sanitize(text)
27
- if text.start_with?("<p>") && text.end_with?("</p>")
27
+ if text.start_with?('<p>') && text.end_with?('</p>')
28
28
  text
29
29
  else
30
30
  "<p>#{text}</p>"
@@ -32,7 +32,7 @@ module Panda
32
32
  end
33
33
 
34
34
  def caption_element(caption)
35
- return "" if caption.blank?
35
+ return '' if caption.blank?
36
36
 
37
37
  "<figcaption>#{sanitize(caption)}</figcaption>"
38
38
  end
@@ -5,8 +5,8 @@ module Panda
5
5
  module Blocks
6
6
  class Table < Base
7
7
  def render
8
- content = data["content"]
9
- with_headings = data["withHeadings"]
8
+ content = data['content']
9
+ with_headings = data['withHeadings']
10
10
 
11
11
  html_safe(<<~HTML)
12
12
  <div class="overflow-x-auto">
@@ -25,10 +25,10 @@ module Panda
25
25
 
26
26
  while index < content.length
27
27
  rows << if index.zero? && with_headings
28
- render_header_row(content[index])
29
- else
30
- render_data_row(content[index])
31
- end
28
+ render_header_row(content[index])
29
+ else
30
+ render_data_row(content[index])
31
+ end
32
32
  index += 1
33
33
  end
34
34
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
5
  module Panda
6
6
  module Editor
@@ -36,21 +36,21 @@ module Panda
36
36
  end
37
37
 
38
38
  def generate_cached_content
39
- renderer_options = {autolink_urls: true}
39
+ renderer_options = { autolink_urls: true }
40
40
 
41
41
  if content.is_a?(String)
42
42
  begin
43
43
  parsed_content = JSON.parse(content)
44
- self.cached_content = if parsed_content.is_a?(Hash) && parsed_content["blocks"].present?
45
- Panda::Editor::Renderer.new(parsed_content, renderer_options).render
46
- else
47
- content
48
- end
44
+ self.cached_content = if parsed_content.is_a?(Hash) && parsed_content['blocks'].present?
45
+ Panda::Editor::Renderer.new(parsed_content, renderer_options).render
46
+ else
47
+ content
48
+ end
49
49
  rescue JSON::ParserError
50
50
  # If it's not JSON, treat it as plain text
51
51
  self.cached_content = content
52
52
  end
53
- elsif content.is_a?(Hash) && content["blocks"].present?
53
+ elsif content.is_a?(Hash) && content['blocks'].present?
54
54
  # Process EditorJS content
55
55
  self.cached_content = Panda::Editor::Renderer.new(content, renderer_options).render
56
56
  else
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rails"
4
- require "sanitize"
3
+ require 'rails'
4
+ require 'sanitize'
5
5
 
6
6
  module Panda
7
7
  module Editor
@@ -12,18 +12,22 @@ module Panda
12
12
  g.test_framework :rspec
13
13
  end
14
14
 
15
- initializer "panda_editor.assets" do |app|
15
+ # Eager load converter classes
16
+ config.to_prepare do
17
+ require 'panda/editor/markdown_to_editor_js_converter'
18
+ require 'panda/editor/html_to_editor_js_converter'
19
+ end
20
+
21
+ initializer 'panda_editor.assets' do |app|
16
22
  next unless app.config.respond_to?(:assets)
17
23
 
18
- app.config.assets.paths << root.join("app/javascript")
19
- app.config.assets.paths << root.join("public")
24
+ app.config.assets.paths << root.join('app/javascript')
25
+ app.config.assets.paths << root.join('public')
20
26
  app.config.assets.precompile += %w[panda/editor/*.js panda/editor/*.css]
21
27
  end
22
28
 
23
- initializer "panda_editor.importmap", before: "importmap" do |app|
24
- if app.config.respond_to?(:importmap)
25
- app.config.importmap.paths << root.join("config/importmap.rb")
26
- end
29
+ initializer 'panda_editor.importmap', before: 'importmap' do |app|
30
+ app.config.importmap.paths << root.join('config/importmap.rb') if app.config.respond_to?(:importmap)
27
31
  end
28
32
  end
29
33
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "redcarpet"
3
+ require 'redcarpet'
4
4
 
5
5
  module Panda
6
6
  module Editor
@@ -19,7 +19,7 @@ module Panda
19
19
  return @footnote_ids[id] if @footnote_ids[id]
20
20
 
21
21
  # Add new footnote
22
- @footnotes << {id: id, content: content}
22
+ @footnotes << { id: id, content: content }
23
23
  number = @footnotes.length
24
24
 
25
25
  # Cache the number for this ID
@@ -29,7 +29,7 @@ module Panda
29
29
  end
30
30
 
31
31
  def render_sources_section
32
- return "" if @footnotes.empty?
32
+ return '' if @footnotes.empty?
33
33
 
34
34
  footnote_items = @footnotes.map.with_index do |footnote, index|
35
35
  number = index + 1
@@ -88,7 +88,7 @@ module Panda
88
88
  no_images: true,
89
89
  no_styles: true,
90
90
  safe_links_only: true,
91
- link_attributes: {target: "_blank", rel: "noopener noreferrer"}
91
+ link_attributes: { target: '_blank', rel: 'noopener noreferrer' }
92
92
  )
93
93
 
94
94
  markdown = Redcarpet::Markdown.new(
@@ -122,12 +122,12 @@ module Panda
122
122
  # Don't replace URLs that are already in <a> tags
123
123
  text.gsub(url_pattern) do |url|
124
124
  # Skip if this URL is already part of an href attribute
125
- before_match = $`
125
+ before_match = ::Regexp.last_match.pre_match
126
126
  if /<a[^>]*href\s*=\s*["']?\z/i.match?(before_match)
127
127
  url
128
128
  else
129
129
  # Add protocol if missing
130
- full_url = url.start_with?("www.") ? "https://#{url}" : url
130
+ full_url = url.start_with?('www.') ? "https://#{url}" : url
131
131
  %(<a href="#{full_url}" target="_blank" rel="noopener noreferrer">#{url}</a>)
132
132
  end
133
133
  end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Panda
6
+ module Editor
7
+ # Converts HTML to EditorJS format
8
+ # Parses HTML and converts it to EditorJS blocks
9
+ class HtmlToEditorJsConverter
10
+ def self.convert(html)
11
+ new(html).convert
12
+ end
13
+
14
+ def initialize(html)
15
+ @html = html
16
+ @blocks = []
17
+ end
18
+
19
+ def convert
20
+ doc = Nokogiri::HTML.fragment(@html)
21
+
22
+ doc.children.each do |node|
23
+ block = node_to_block(node)
24
+ @blocks << block if block
25
+ end
26
+
27
+ {
28
+ time: Time.now.to_i * 1000,
29
+ blocks: @blocks,
30
+ version: "2.28.0"
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def node_to_block(node)
37
+ return nil if node.text? && node.text.strip.empty?
38
+
39
+ case node.name
40
+ when "h1", "h2", "h3", "h4", "h5", "h6"
41
+ header_block(node)
42
+ when "p"
43
+ paragraph_block(node)
44
+ when "ul", "ol"
45
+ list_block(node)
46
+ when "blockquote"
47
+ quote_block(node)
48
+ when "pre"
49
+ code_block(node)
50
+ when "table"
51
+ table_block(node)
52
+ when "hr"
53
+ delimiter_block
54
+ when "text"
55
+ # Handle text nodes that aren't wrapped in tags
56
+ text = node.text.strip
57
+ text.empty? ? nil : paragraph_block_from_text(text)
58
+ else
59
+ # For any other node, try to extract text content
60
+ text = node.text.strip
61
+ text.empty? ? nil : paragraph_block_from_text(text)
62
+ end
63
+ end
64
+
65
+ def header_block(node)
66
+ level = node.name[1].to_i
67
+ {
68
+ type: "header",
69
+ data: {
70
+ text: node.inner_html.strip,
71
+ level: level
72
+ }
73
+ }
74
+ end
75
+
76
+ def paragraph_block(node)
77
+ text = node.inner_html.strip
78
+ return nil if text.empty?
79
+
80
+ {
81
+ type: "paragraph",
82
+ data: {
83
+ text: text
84
+ }
85
+ }
86
+ end
87
+
88
+ def paragraph_block_from_text(text)
89
+ {
90
+ type: "paragraph",
91
+ data: {
92
+ text: text
93
+ }
94
+ }
95
+ end
96
+
97
+ def list_block(node)
98
+ style = node.name == "ol" ? "ordered" : "unordered"
99
+ items = node.css("li").map { |li| li.inner_html.strip }
100
+
101
+ {
102
+ type: "list",
103
+ data: {
104
+ style: style,
105
+ items: items
106
+ }
107
+ }
108
+ end
109
+
110
+ def quote_block(node)
111
+ {
112
+ type: "quote",
113
+ data: {
114
+ text: node.inner_html.strip,
115
+ caption: "",
116
+ alignment: "left"
117
+ }
118
+ }
119
+ end
120
+
121
+ def code_block(node)
122
+ code = node.css("code").first
123
+ text = code ? code.text : node.text
124
+
125
+ {
126
+ type: "code",
127
+ data: {
128
+ code: text
129
+ }
130
+ }
131
+ end
132
+
133
+ def table_block(node)
134
+ content = []
135
+
136
+ # Process table rows
137
+ node.css("tr").each do |row|
138
+ cells = row.css("th, td").map { |cell| cell.inner_html.strip }
139
+ content << cells
140
+ end
141
+
142
+ {
143
+ type: "table",
144
+ data: {
145
+ withHeadings: node.css("thead").any? || node.css("th").any?,
146
+ content: content
147
+ }
148
+ }
149
+ end
150
+
151
+ def delimiter_block
152
+ {
153
+ type: "delimiter",
154
+ data: {}
155
+ }
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redcarpet"
4
+
5
+ module Panda
6
+ module Editor
7
+ # Converts Markdown to EditorJS format
8
+ # Uses Redcarpet to parse markdown to HTML, then converts HTML to EditorJS blocks
9
+ class MarkdownToEditorJsConverter
10
+ def self.convert(markdown)
11
+ new(markdown).convert
12
+ end
13
+
14
+ def initialize(markdown)
15
+ @markdown = markdown
16
+ end
17
+
18
+ def convert
19
+ # Step 1: Convert Markdown to HTML using Redcarpet
20
+ html = markdown_to_html
21
+
22
+ # Step 2: Convert HTML to EditorJS using existing converter
23
+ Panda::Editor::HtmlToEditorJsConverter.convert(html)
24
+ end
25
+
26
+ private
27
+
28
+ def markdown_to_html
29
+ renderer = Redcarpet::Render::HTML.new(
30
+ hard_wrap: true,
31
+ link_attributes: {rel: "noopener noreferrer"}
32
+ )
33
+
34
+ markdown_processor = Redcarpet::Markdown.new(
35
+ renderer,
36
+ autolink: true,
37
+ tables: true,
38
+ fenced_code_blocks: true,
39
+ strikethrough: true,
40
+ superscript: true,
41
+ footnotes: true,
42
+ no_intra_emphasis: true,
43
+ space_after_headers: true
44
+ )
45
+
46
+ markdown_processor.render(@markdown)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "sanitize"
3
+ require 'sanitize'
4
4
 
5
5
  module Panda
6
6
  module Editor
@@ -20,10 +20,10 @@ module Panda
20
20
  end
21
21
 
22
22
  def render
23
- return "" if content.nil? || content == {}
24
- return content.to_s unless content.is_a?(Hash) && content["blocks"].is_a?(Array)
23
+ return '' if content.nil? || content == {}
24
+ return content.to_s unless content.is_a?(Hash) && content['blocks'].is_a?(Array)
25
25
 
26
- rendered = content["blocks"].map do |block|
26
+ rendered = content['blocks'].map do |block|
27
27
  render_block(block)
28
28
  end.join("\n")
29
29
 
@@ -35,36 +35,36 @@ module Panda
35
35
  rendered = [rendered, sources_section].join("\n")
36
36
  end
37
37
 
38
- rendered.presence || ""
38
+ rendered.presence || ''
39
39
  end
40
40
 
41
41
  def section(blocks)
42
- return "" if blocks.nil? || blocks.empty?
42
+ return '' if blocks.nil? || blocks.empty?
43
43
 
44
- content = {"blocks" => blocks}
44
+ content = { 'blocks' => blocks }
45
45
  rendered = self.class.new(content, options).render
46
46
 
47
47
  "<section class=\"content-section\">#{rendered}</section>"
48
48
  end
49
49
 
50
50
  def article(blocks, title: nil)
51
- return "" if blocks.nil? || blocks.empty?
51
+ return '' if blocks.nil? || blocks.empty?
52
52
 
53
- content = {"blocks" => blocks}
53
+ content = { 'blocks' => blocks }
54
54
  rendered = self.class.new(content, options).render
55
55
 
56
56
  [
57
- "<article>",
58
- (title ? "<h1>#{title}</h1>" : ""),
57
+ '<article>',
58
+ (title ? "<h1>#{title}</h1>" : ''),
59
59
  rendered,
60
- "</article>"
60
+ '</article>'
61
61
  ].join("\n")
62
62
  end
63
63
 
64
64
  private
65
65
 
66
66
  def validate_html(html)
67
- return "" if html.blank?
67
+ return '' if html.blank?
68
68
 
69
69
  begin
70
70
  # For quote blocks, only allow specific content
@@ -73,35 +73,35 @@ module Panda
73
73
  valid_content = '<figure class="text-left"><blockquote><p>Valid HTML</p></blockquote><figcaption>Valid caption</figcaption></figure>'
74
74
  return html if html.strip == valid_content.strip
75
75
 
76
- return ""
76
+ return ''
77
77
  end
78
78
 
79
79
  # For other HTML, use sanitize
80
80
  config = Sanitize::Config::RELAXED.dup
81
81
  config[:elements] += %w[figure figcaption blockquote pre code mention math]
82
82
  config[:attributes].merge!({
83
- "figure" => ["class"],
84
- "blockquote" => ["class"],
85
- "p" => ["class"],
86
- "figcaption" => ["class"]
87
- })
83
+ 'figure' => ['class'],
84
+ 'blockquote' => ['class'],
85
+ 'p' => ['class'],
86
+ 'figcaption' => ['class']
87
+ })
88
88
 
89
89
  sanitized = Sanitize.fragment(html, config)
90
- (sanitized == html) ? html : ""
91
- rescue => e
90
+ sanitized == html ? html : ''
91
+ rescue StandardError => e
92
92
  Rails.logger.error("HTML validation error: #{e.message}")
93
- ""
93
+ ''
94
94
  end
95
95
  end
96
96
 
97
97
  def render_block_with_cache(block)
98
98
  # Don't cache blocks with footnotes - they need to register with the footnote registry
99
- if block["data"]["footnotes"].present?
99
+ if block['data']['footnotes'].present?
100
100
  renderer = renderer_for(block)
101
101
  return renderer.render
102
102
  end
103
103
 
104
- cache_key = "editor_js_block/#{block["type"]}/#{Digest::MD5.hexdigest(block["data"].to_json)}"
104
+ cache_key = "editor_js_block/#{block['type']}/#{Digest::MD5.hexdigest(block['data'].to_json)}"
105
105
 
106
106
  cache_store.fetch(cache_key) do
107
107
  renderer = renderer_for(block)
@@ -110,28 +110,28 @@ module Panda
110
110
  end
111
111
 
112
112
  def renderer_for(block)
113
- if custom_renderers[block["type"]]
114
- custom_renderers[block["type"]].new(block["data"], options)
113
+ if custom_renderers[block['type']]
114
+ custom_renderers[block['type']].new(block['data'], options)
115
115
  else
116
116
  default_renderer_for(block)
117
117
  end
118
118
  end
119
119
 
120
120
  def default_renderer_for(block)
121
- renderer_class = "Panda::Editor::Blocks::#{block["type"].classify}".constantize
122
- renderer_class.new(block["data"], options)
121
+ renderer_class = "Panda::Editor::Blocks::#{block['type'].classify}".constantize
122
+ renderer_class.new(block['data'], options)
123
123
  rescue NameError
124
- Panda::Editor::Blocks::Base.new(block["data"], options)
124
+ Panda::Editor::Blocks::Base.new(block['data'], options)
125
125
  end
126
126
 
127
127
  def remove_empty_paragraphs(blocks)
128
128
  blocks.reject do |block|
129
- block["type"] == "paragraph" && block["data"]["text"].blank?
129
+ block['type'] == 'paragraph' && block['data']['text'].blank?
130
130
  end
131
131
  end
132
132
 
133
133
  def empty_paragraph?(block)
134
- block["type"] == "paragraph" && block["data"]["text"].blank?
134
+ block['type'] == 'paragraph' && block['data']['text'].blank?
135
135
  end
136
136
 
137
137
  def render_block(block)