panda-editor 0.3.0 → 0.5.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,16 @@ module Panda
12
12
  g.test_framework :rspec
13
13
  end
14
14
 
15
- initializer "panda_editor.assets" do |app|
15
+ initializer 'panda_editor.assets' do |app|
16
16
  next unless app.config.respond_to?(:assets)
17
17
 
18
- app.config.assets.paths << root.join("app/javascript")
19
- app.config.assets.paths << root.join("public")
18
+ app.config.assets.paths << root.join('app/javascript')
19
+ app.config.assets.paths << root.join('public')
20
20
  app.config.assets.precompile += %w[panda/editor/*.js panda/editor/*.css]
21
21
  end
22
22
 
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
23
+ initializer 'panda_editor.importmap', before: 'importmap' do |app|
24
+ app.config.importmap.paths << root.join('config/importmap.rb') if app.config.respond_to?(:importmap)
27
25
  end
28
26
  end
29
27
  end
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'redcarpet'
4
+
3
5
  module Panda
4
6
  module Editor
5
7
  class FootnoteRegistry
6
8
  attr_reader :footnotes
7
9
 
8
- def initialize(autolink_urls: false)
10
+ def initialize(autolink_urls: false, markdown: false)
9
11
  @footnotes = []
10
12
  @footnote_ids = {}
11
13
  @autolink_urls = autolink_urls
14
+ @markdown = markdown
12
15
  end
13
16
 
14
17
  def add(id:, content:)
@@ -16,7 +19,7 @@ module Panda
16
19
  return @footnote_ids[id] if @footnote_ids[id]
17
20
 
18
21
  # Add new footnote
19
- @footnotes << {id: id, content: content}
22
+ @footnotes << { id: id, content: content }
20
23
  number = @footnotes.length
21
24
 
22
25
  # Cache the number for this ID
@@ -26,11 +29,11 @@ module Panda
26
29
  end
27
30
 
28
31
  def render_sources_section
29
- return "" if @footnotes.empty?
32
+ return '' if @footnotes.empty?
30
33
 
31
34
  footnote_items = @footnotes.map.with_index do |footnote, index|
32
35
  number = index + 1
33
- content = @autolink_urls ? autolink_urls(footnote[:content]) : footnote[:content]
36
+ content = process_content(footnote[:content])
34
37
  <<~HTML.strip
35
38
  <li id="fn:#{number}">
36
39
  <p>
@@ -66,6 +69,45 @@ module Panda
66
69
 
67
70
  private
68
71
 
72
+ def process_content(content)
73
+ # Apply markdown processing if enabled
74
+ content = render_markdown(content) if @markdown
75
+
76
+ # Apply URL autolinking if enabled
77
+ # Note: Markdown already includes autolink, but custom autolink_urls can still be used
78
+ # if needed for additional URL patterns. The autolink_urls method skips already-linked URLs.
79
+ content = autolink_urls(content) if @autolink_urls
80
+
81
+ content
82
+ end
83
+
84
+ def render_markdown(text)
85
+ # Configure Redcarpet with safe options for footnotes
86
+ renderer = Redcarpet::Render::HTML.new(
87
+ filter_html: false,
88
+ no_images: true,
89
+ no_styles: true,
90
+ safe_links_only: true,
91
+ link_attributes: { target: '_blank', rel: 'noopener noreferrer' }
92
+ )
93
+
94
+ markdown = Redcarpet::Markdown.new(
95
+ renderer,
96
+ autolink: true,
97
+ space_after_headers: true,
98
+ fenced_code_blocks: false,
99
+ no_intra_emphasis: true,
100
+ strikethrough: true,
101
+ superscript: false,
102
+ underline: false
103
+ )
104
+
105
+ # Render markdown and strip the wrapping <p> tags if present
106
+ # since we're already wrapping in <p> tags in the template
107
+ html = markdown.render(text).strip
108
+ html.gsub(%r{^<p>(.*)</p>$}m, '\1')
109
+ end
110
+
69
111
  def autolink_urls(text)
70
112
  # Regex to match URLs that aren't already in <a> tags
71
113
  # Matches http://, https://, and other common protocols
@@ -80,12 +122,12 @@ module Panda
80
122
  # Don't replace URLs that are already in <a> tags
81
123
  text.gsub(url_pattern) do |url|
82
124
  # Skip if this URL is already part of an href attribute
83
- before_match = $`
125
+ before_match = ::Regexp.last_match.pre_match
84
126
  if /<a[^>]*href\s*=\s*["']?\z/i.match?(before_match)
85
127
  url
86
128
  else
87
129
  # Add protocol if missing
88
- full_url = url.start_with?("www.") ? "https://#{url}" : url
130
+ full_url = url.start_with?('www.') ? "https://#{url}" : url
89
131
  %(<a href="#{full_url}" target="_blank" rel="noopener noreferrer">#{url}</a>)
90
132
  end
91
133
  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
@@ -14,15 +14,16 @@ module Panda
14
14
  @cache_store = options.delete(:cache_store) || Rails.cache
15
15
  @validate_html = options.delete(:validate_html) || false
16
16
  autolink_urls = options.delete(:autolink_urls) || false
17
- @footnote_registry = FootnoteRegistry.new(autolink_urls: autolink_urls)
17
+ markdown = options.delete(:markdown) || false
18
+ @footnote_registry = FootnoteRegistry.new(autolink_urls: autolink_urls, markdown: markdown)
18
19
  @options[:footnote_registry] = @footnote_registry
19
20
  end
20
21
 
21
22
  def render
22
- return "" if content.nil? || content == {}
23
- 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)
24
25
 
25
- rendered = content["blocks"].map do |block|
26
+ rendered = content['blocks'].map do |block|
26
27
  render_block(block)
27
28
  end.join("\n")
28
29
 
@@ -34,36 +35,36 @@ module Panda
34
35
  rendered = [rendered, sources_section].join("\n")
35
36
  end
36
37
 
37
- rendered.presence || ""
38
+ rendered.presence || ''
38
39
  end
39
40
 
40
41
  def section(blocks)
41
- return "" if blocks.nil? || blocks.empty?
42
+ return '' if blocks.nil? || blocks.empty?
42
43
 
43
- content = {"blocks" => blocks}
44
+ content = { 'blocks' => blocks }
44
45
  rendered = self.class.new(content, options).render
45
46
 
46
47
  "<section class=\"content-section\">#{rendered}</section>"
47
48
  end
48
49
 
49
50
  def article(blocks, title: nil)
50
- return "" if blocks.nil? || blocks.empty?
51
+ return '' if blocks.nil? || blocks.empty?
51
52
 
52
- content = {"blocks" => blocks}
53
+ content = { 'blocks' => blocks }
53
54
  rendered = self.class.new(content, options).render
54
55
 
55
56
  [
56
- "<article>",
57
- (title ? "<h1>#{title}</h1>" : ""),
57
+ '<article>',
58
+ (title ? "<h1>#{title}</h1>" : ''),
58
59
  rendered,
59
- "</article>"
60
+ '</article>'
60
61
  ].join("\n")
61
62
  end
62
63
 
63
64
  private
64
65
 
65
66
  def validate_html(html)
66
- return "" if html.blank?
67
+ return '' if html.blank?
67
68
 
68
69
  begin
69
70
  # For quote blocks, only allow specific content
@@ -72,35 +73,35 @@ module Panda
72
73
  valid_content = '<figure class="text-left"><blockquote><p>Valid HTML</p></blockquote><figcaption>Valid caption</figcaption></figure>'
73
74
  return html if html.strip == valid_content.strip
74
75
 
75
- return ""
76
+ return ''
76
77
  end
77
78
 
78
79
  # For other HTML, use sanitize
79
80
  config = Sanitize::Config::RELAXED.dup
80
81
  config[:elements] += %w[figure figcaption blockquote pre code mention math]
81
82
  config[:attributes].merge!({
82
- "figure" => ["class"],
83
- "blockquote" => ["class"],
84
- "p" => ["class"],
85
- "figcaption" => ["class"]
86
- })
83
+ 'figure' => ['class'],
84
+ 'blockquote' => ['class'],
85
+ 'p' => ['class'],
86
+ 'figcaption' => ['class']
87
+ })
87
88
 
88
89
  sanitized = Sanitize.fragment(html, config)
89
- (sanitized == html) ? html : ""
90
- rescue => e
90
+ sanitized == html ? html : ''
91
+ rescue StandardError => e
91
92
  Rails.logger.error("HTML validation error: #{e.message}")
92
- ""
93
+ ''
93
94
  end
94
95
  end
95
96
 
96
97
  def render_block_with_cache(block)
97
98
  # Don't cache blocks with footnotes - they need to register with the footnote registry
98
- if block["data"]["footnotes"].present?
99
+ if block['data']['footnotes'].present?
99
100
  renderer = renderer_for(block)
100
101
  return renderer.render
101
102
  end
102
103
 
103
- 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)}"
104
105
 
105
106
  cache_store.fetch(cache_key) do
106
107
  renderer = renderer_for(block)
@@ -109,28 +110,28 @@ module Panda
109
110
  end
110
111
 
111
112
  def renderer_for(block)
112
- if custom_renderers[block["type"]]
113
- custom_renderers[block["type"]].new(block["data"], options)
113
+ if custom_renderers[block['type']]
114
+ custom_renderers[block['type']].new(block['data'], options)
114
115
  else
115
116
  default_renderer_for(block)
116
117
  end
117
118
  end
118
119
 
119
120
  def default_renderer_for(block)
120
- renderer_class = "Panda::Editor::Blocks::#{block["type"].classify}".constantize
121
- renderer_class.new(block["data"], options)
121
+ renderer_class = "Panda::Editor::Blocks::#{block['type'].classify}".constantize
122
+ renderer_class.new(block['data'], options)
122
123
  rescue NameError
123
- Panda::Editor::Blocks::Base.new(block["data"], options)
124
+ Panda::Editor::Blocks::Base.new(block['data'], options)
124
125
  end
125
126
 
126
127
  def remove_empty_paragraphs(blocks)
127
128
  blocks.reject do |block|
128
- block["type"] == "paragraph" && block["data"]["text"].blank?
129
+ block['type'] == 'paragraph' && block['data']['text'].blank?
129
130
  end
130
131
  end
131
132
 
132
133
  def empty_paragraph?(block)
133
- block["type"] == "paragraph" && block["data"]["text"].blank?
134
+ block['type'] == 'paragraph' && block['data']['text'].blank?
134
135
  end
135
136
 
136
137
  def render_block(block)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Panda
4
4
  module Editor
5
- VERSION = "0.3.0"
5
+ VERSION = '0.5.0'
6
6
  end
7
7
  end
data/lib/panda/editor.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "dry-configurable"
4
- require_relative "editor/version"
5
- require_relative "editor/engine"
3
+ require 'dry-configurable'
4
+ require_relative 'editor/version'
5
+ require_relative 'editor/engine'
6
6
 
7
7
  module Panda
8
8
  module Editor
@@ -18,19 +18,19 @@ module Panda
18
18
  class Error < StandardError; end
19
19
 
20
20
  # Autoload components
21
- autoload :Renderer, "panda/editor/renderer"
22
- autoload :Content, "panda/editor/content"
23
- autoload :FootnoteRegistry, "panda/editor/footnote_registry"
21
+ autoload :Renderer, 'panda/editor/renderer'
22
+ autoload :Content, 'panda/editor/content'
23
+ autoload :FootnoteRegistry, 'panda/editor/footnote_registry'
24
24
 
25
25
  module Blocks
26
- autoload :Base, "panda/editor/blocks/base"
27
- autoload :Alert, "panda/editor/blocks/alert"
28
- autoload :Header, "panda/editor/blocks/header"
29
- autoload :Image, "panda/editor/blocks/image"
30
- autoload :List, "panda/editor/blocks/list"
31
- autoload :Paragraph, "panda/editor/blocks/paragraph"
32
- autoload :Quote, "panda/editor/blocks/quote"
33
- autoload :Table, "panda/editor/blocks/table"
26
+ autoload :Base, 'panda/editor/blocks/base'
27
+ autoload :Alert, 'panda/editor/blocks/alert'
28
+ autoload :Header, 'panda/editor/blocks/header'
29
+ autoload :Image, 'panda/editor/blocks/image'
30
+ autoload :List, 'panda/editor/blocks/list'
31
+ autoload :Paragraph, 'panda/editor/blocks/paragraph'
32
+ autoload :Quote, 'panda/editor/blocks/quote'
33
+ autoload :Table, 'panda/editor/blocks/table'
34
34
  end
35
35
  end
36
36
  end