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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +68 -0
- data/app/services/panda/editor/html_to_editor_js_converter.rb +68 -68
- data/config/importmap.rb +9 -9
- data/lib/panda/editor/asset_loader.rb +30 -32
- data/lib/panda/editor/blocks/alert.rb +10 -10
- data/lib/panda/editor/blocks/base.rb +1 -1
- data/lib/panda/editor/blocks/header.rb +2 -2
- data/lib/panda/editor/blocks/image.rb +11 -11
- data/lib/panda/editor/blocks/list.rb +5 -5
- data/lib/panda/editor/blocks/paragraph.rb +10 -10
- data/lib/panda/editor/blocks/quote.rb +6 -6
- data/lib/panda/editor/blocks/table.rb +6 -6
- data/lib/panda/editor/content.rb +8 -8
- data/lib/panda/editor/engine.rb +13 -9
- data/lib/panda/editor/footnote_registry.rb +6 -6
- data/lib/panda/editor/html_to_editor_js_converter.rb +159 -0
- data/lib/panda/editor/markdown_to_editor_js_converter.rb +50 -0
- data/lib/panda/editor/renderer.rb +31 -31
- data/lib/panda/editor/version.rb +1 -1
- data/lib/panda/editor.rb +17 -15
- data/lib/panda-editor.rb +5 -0
- data/lib/tasks/assets.rake +28 -28
- data/panda-editor.gemspec +24 -23
- metadata +35 -21
|
@@ -5,19 +5,19 @@ module Panda
|
|
|
5
5
|
module Blocks
|
|
6
6
|
class Image < Base
|
|
7
7
|
def render
|
|
8
|
-
url = data[
|
|
9
|
-
caption = sanitize(data[
|
|
10
|
-
with_border = data[
|
|
11
|
-
with_background = data[
|
|
12
|
-
stretched = data[
|
|
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 = [
|
|
15
|
-
css_classes <<
|
|
16
|
-
css_classes <<
|
|
17
|
-
css_classes <<
|
|
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
|
|
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 =
|
|
8
|
+
list_type = data['style'] == 'ordered' ? 'ol' : 'ul'
|
|
9
9
|
html_safe(
|
|
10
10
|
"<#{list_type}>" \
|
|
11
|
-
"#{render_items(data[
|
|
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[
|
|
21
|
-
nested =
|
|
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({
|
|
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[
|
|
9
|
-
return
|
|
8
|
+
content = sanitize(data['text'])
|
|
9
|
+
return '' if content.blank?
|
|
10
10
|
|
|
11
|
-
content = inject_footnotes(content) if data[
|
|
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[
|
|
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[
|
|
22
|
+
footnotes = data['footnotes'].sort_by { |fn| -fn['position'].to_i }
|
|
23
23
|
|
|
24
24
|
footnotes.each do |footnote|
|
|
25
|
-
position = footnote[
|
|
25
|
+
position = footnote['position'].to_i
|
|
26
26
|
# Skip if position is beyond text length
|
|
27
|
-
next if position
|
|
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
|
|
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[
|
|
48
|
-
content: footnote[
|
|
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[
|
|
9
|
-
caption = data[
|
|
10
|
-
alignment = data[
|
|
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
|
-
|
|
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?(
|
|
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
|
|
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[
|
|
9
|
-
with_headings = data[
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
data/lib/panda/editor/content.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
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[
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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[
|
|
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
|
data/lib/panda/editor/engine.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
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
|
-
|
|
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(
|
|
19
|
-
app.config.assets.paths << root.join(
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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?(
|
|
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
|
|
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
|
|
24
|
-
return content.to_s unless content.is_a?(Hash) && content[
|
|
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[
|
|
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
|
|
42
|
+
return '' if blocks.nil? || blocks.empty?
|
|
43
43
|
|
|
44
|
-
content = {
|
|
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
|
|
51
|
+
return '' if blocks.nil? || blocks.empty?
|
|
52
52
|
|
|
53
|
-
content = {
|
|
53
|
+
content = { 'blocks' => blocks }
|
|
54
54
|
rendered = self.class.new(content, options).render
|
|
55
55
|
|
|
56
56
|
[
|
|
57
|
-
|
|
58
|
-
(title ? "<h1>#{title}</h1>" :
|
|
57
|
+
'<article>',
|
|
58
|
+
(title ? "<h1>#{title}</h1>" : ''),
|
|
59
59
|
rendered,
|
|
60
|
-
|
|
60
|
+
'</article>'
|
|
61
61
|
].join("\n")
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
private
|
|
65
65
|
|
|
66
66
|
def validate_html(html)
|
|
67
|
-
return
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
'figure' => ['class'],
|
|
84
|
+
'blockquote' => ['class'],
|
|
85
|
+
'p' => ['class'],
|
|
86
|
+
'figcaption' => ['class']
|
|
87
|
+
})
|
|
88
88
|
|
|
89
89
|
sanitized = Sanitize.fragment(html, config)
|
|
90
|
-
|
|
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[
|
|
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[
|
|
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[
|
|
114
|
-
custom_renderers[block[
|
|
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[
|
|
122
|
-
renderer_class.new(block[
|
|
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[
|
|
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[
|
|
129
|
+
block['type'] == 'paragraph' && block['data']['text'].blank?
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
def empty_paragraph?(block)
|
|
134
|
-
block[
|
|
134
|
+
block['type'] == 'paragraph' && block['data']['text'].blank?
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def render_block(block)
|