panda-editor 0.2.1 → 0.4.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.
@@ -12,13 +12,9 @@ module Panda
12
12
  g.test_framework :rspec
13
13
  end
14
14
 
15
- # Allow applications to configure editor tools
16
- config.editor_js_tools = []
17
-
18
- # Custom block renderers
19
- config.custom_renderers = {}
20
-
21
15
  initializer "panda_editor.assets" do |app|
16
+ next unless app.config.respond_to?(:assets)
17
+
22
18
  app.config.assets.paths << root.join("app/javascript")
23
19
  app.config.assets.paths << root.join("public")
24
20
  app.config.assets.precompile += %w[panda/editor/*.js panda/editor/*.css]
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redcarpet"
4
+
5
+ module Panda
6
+ module Editor
7
+ class FootnoteRegistry
8
+ attr_reader :footnotes
9
+
10
+ def initialize(autolink_urls: false, markdown: false)
11
+ @footnotes = []
12
+ @footnote_ids = {}
13
+ @autolink_urls = autolink_urls
14
+ @markdown = markdown
15
+ end
16
+
17
+ def add(id:, content:)
18
+ # Return existing number if this ID was already registered
19
+ return @footnote_ids[id] if @footnote_ids[id]
20
+
21
+ # Add new footnote
22
+ @footnotes << {id: id, content: content}
23
+ number = @footnotes.length
24
+
25
+ # Cache the number for this ID
26
+ @footnote_ids[id] = number
27
+
28
+ number
29
+ end
30
+
31
+ def render_sources_section
32
+ return "" if @footnotes.empty?
33
+
34
+ footnote_items = @footnotes.map.with_index do |footnote, index|
35
+ number = index + 1
36
+ content = process_content(footnote[:content])
37
+ <<~HTML.strip
38
+ <li id="fn:#{number}">
39
+ <p>
40
+ #{content}
41
+ <a href="#fnref:#{number}" class="footnote-backref">↩</a>
42
+ </p>
43
+ </li>
44
+ HTML
45
+ end.join("\n")
46
+
47
+ <<~HTML
48
+ <div class="mx-6 lg:mx-8 mt-4 mb-8">
49
+ <div class="footnotes-section bg-gray-50 rounded-lg overflow-hidden">
50
+ <button class="footnotes-header w-full px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-100 transition-colors" data-footnotes-target="toggle" data-action="click->footnotes#toggle">
51
+ <h3 class="text-sm font-unbounded font-medium text-gray-900 m-0">Sources/References</h3>
52
+ <svg class="footnotes-chevron w-5 h-5 text-gray-600" data-footnotes-target="chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
53
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
54
+ </svg>
55
+ </button>
56
+ <div class="footnotes-content" data-footnotes-target="content">
57
+ <ol class="footnotes text-sm text-gray-700 space-y-2 px-4 pb-3">
58
+ #{footnote_items}
59
+ </ol>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ HTML
64
+ end
65
+
66
+ def any?
67
+ @footnotes.any?
68
+ end
69
+
70
+ private
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
+
111
+ def autolink_urls(text)
112
+ # Regex to match URLs that aren't already in <a> tags
113
+ # Matches http://, https://, and other common protocols
114
+ url_pattern = %r{
115
+ (?<!["'>]) # Not preceded by quotes or >
116
+ \b # Word boundary
117
+ (https?://|ftp://|www\.) # Protocol or www
118
+ [^\s<>"']+ # URL characters (not whitespace, <, >, quotes)
119
+ [^\s<>"'.,;:!?)\]] # Ends with non-punctuation
120
+ }x
121
+
122
+ # Don't replace URLs that are already in <a> tags
123
+ text.gsub(url_pattern) do |url|
124
+ # Skip if this URL is already part of an href attribute
125
+ before_match = $`
126
+ if /<a[^>]*href\s*=\s*["']?\z/i.match?(before_match)
127
+ url
128
+ else
129
+ # Add protocol if missing
130
+ full_url = url.start_with?("www.") ? "https://#{url}" : url
131
+ %(<a href="#{full_url}" target="_blank" rel="noopener noreferrer">#{url}</a>)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -5,7 +5,7 @@ require "sanitize"
5
5
  module Panda
6
6
  module Editor
7
7
  class Renderer
8
- attr_reader :content, :options, :custom_renderers, :cache_store
8
+ attr_reader :content, :options, :custom_renderers, :cache_store, :footnote_registry
9
9
 
10
10
  def initialize(content, options = {})
11
11
  @content = content
@@ -13,6 +13,10 @@ module Panda
13
13
  @custom_renderers = options.delete(:custom_renderers) || {}
14
14
  @cache_store = options.delete(:cache_store) || Rails.cache
15
15
  @validate_html = options.delete(:validate_html) || false
16
+ autolink_urls = options.delete(:autolink_urls) || false
17
+ markdown = options.delete(:markdown) || false
18
+ @footnote_registry = FootnoteRegistry.new(autolink_urls: autolink_urls, markdown: markdown)
19
+ @options[:footnote_registry] = @footnote_registry
16
20
  end
17
21
 
18
22
  def render
@@ -24,6 +28,13 @@ module Panda
24
28
  end.join("\n")
25
29
 
26
30
  rendered = @validate_html ? validate_html(rendered) : rendered
31
+
32
+ # Append sources section if footnotes were collected
33
+ if @footnote_registry.any?
34
+ sources_section = @footnote_registry.render_sources_section
35
+ rendered = [rendered, sources_section].join("\n")
36
+ end
37
+
27
38
  rendered.presence || ""
28
39
  end
29
40
 
@@ -84,6 +95,12 @@ module Panda
84
95
  end
85
96
 
86
97
  def render_block_with_cache(block)
98
+ # Don't cache blocks with footnotes - they need to register with the footnote registry
99
+ if block["data"]["footnotes"].present?
100
+ renderer = renderer_for(block)
101
+ return renderer.render
102
+ end
103
+
87
104
  cache_key = "editor_js_block/#{block["type"]}/#{Digest::MD5.hexdigest(block["data"].to_json)}"
88
105
 
89
106
  cache_store.fetch(cache_key) do
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Panda
4
4
  module Editor
5
- VERSION = "0.2.1"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
data/lib/panda/editor.rb CHANGED
@@ -1,15 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dry-configurable"
3
4
  require_relative "editor/version"
4
5
  require_relative "editor/engine"
5
6
 
6
7
  module Panda
7
8
  module Editor
9
+ extend Dry::Configurable
10
+
11
+ # EditorJS configuration
12
+ setting :editor_js_tools, default: []
13
+ setting :editor_js_tool_config, default: {}
14
+
15
+ # Custom block renderers
16
+ setting :custom_renderers, default: {}
17
+
8
18
  class Error < StandardError; end
9
19
 
10
20
  # Autoload components
11
21
  autoload :Renderer, "panda/editor/renderer"
12
22
  autoload :Content, "panda/editor/content"
23
+ autoload :FootnoteRegistry, "panda/editor/footnote_registry"
13
24
 
14
25
  module Blocks
15
26
  autoload :Base, "panda/editor/blocks/base"
data/panda-editor.gemspec CHANGED
@@ -5,8 +5,8 @@ require_relative "lib/panda/editor/version"
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = "panda-editor"
7
7
  spec.version = Panda::Editor::VERSION
8
- spec.authors = ["Panda CMS Team"]
9
- spec.email = ["hello@pandacms.io"]
8
+ spec.authors = ["Otaina Limited", "James Inman"]
9
+ spec.email = ["james@otaina.co.uk"]
10
10
 
11
11
  spec.summary = "EditorJS integration for Rails applications"
12
12
  spec.description = "A modular, extensible rich text editor using EditorJS for Rails applications. Extracted from Panda CMS."
@@ -33,6 +33,8 @@ Gem::Specification.new do |spec|
33
33
  # Rails dependencies
34
34
  spec.add_dependency "rails", ">= 7.1"
35
35
  spec.add_dependency "sanitize", "~> 6.0"
36
+ spec.add_dependency "dry-configurable", "~> 1.0"
37
+ spec.add_dependency "redcarpet", "~> 3.6"
36
38
 
37
39
  # Development dependencies
38
40
  spec.add_development_dependency "rspec-rails", "~> 6.0"