panda_cms 0.6.0 → 0.6.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/panda_cms.css +17 -0
  3. data/app/assets/config/panda_cms_manifest.js +1 -0
  4. data/app/components/panda_cms/code_component.rb +3 -1
  5. data/app/components/panda_cms/menu_component.html.erb +3 -0
  6. data/app/components/panda_cms/menu_component.rb +8 -1
  7. data/app/components/panda_cms/page_menu_component.html.erb +7 -5
  8. data/app/components/panda_cms/page_menu_component.rb +3 -1
  9. data/app/components/panda_cms/rich_text_component.html.erb +2 -2
  10. data/app/components/panda_cms/rich_text_component.rb +12 -4
  11. data/app/components/panda_cms/text_component.rb +1 -1
  12. data/app/controllers/panda_cms/admin/block_contents_controller.rb +1 -1
  13. data/app/javascript/panda_cms/@editorjs--editorjs.js +2577 -0
  14. data/app/javascript/panda_cms/controllers/editor_controller.js +247 -0
  15. data/app/javascript/panda_cms/controllers/index.js +10 -7
  16. data/app/javascript/panda_cms/editor/plain_text_editor.js +102 -0
  17. data/app/javascript/panda_cms/editor/resource_loader.js +69 -0
  18. data/app/javascript/panda_cms/editor/rich_text_editor.js +89 -0
  19. data/app/lib/panda_cms/demo_site_generator.rb +8 -9
  20. data/app/lib/panda_cms/editor_js/blocks/alert.rb +32 -0
  21. data/app/lib/panda_cms/editor_js/blocks/base.rb +28 -0
  22. data/app/lib/panda_cms/editor_js/blocks/header.rb +13 -0
  23. data/app/lib/panda_cms/editor_js/blocks/image.rb +34 -0
  24. data/app/lib/panda_cms/editor_js/blocks/list.rb +30 -0
  25. data/app/lib/panda_cms/editor_js/blocks/paragraph.rb +13 -0
  26. data/app/lib/panda_cms/editor_js/blocks/quote.rb +27 -0
  27. data/app/lib/panda_cms/editor_js/blocks/table.rb +48 -0
  28. data/app/lib/panda_cms/editor_js/renderer.rb +120 -0
  29. data/app/models/panda_cms/block_content.rb +12 -2
  30. data/app/views/panda_cms/admin/pages/edit.html.erb +10 -9
  31. data/app/views/panda_cms/shared/_header.html.erb +2 -3
  32. data/app/views/panda_cms/shared/_importmap.html.erb +13 -3
  33. data/config/importmap.rb +3 -1
  34. data/db/migrate/20240315125411_add_status_to_panda_cms_pages.rb +5 -3
  35. data/db/migrate/20241031205109_add_cached_content_to_panda_cms_block_contents.rb +5 -0
  36. data/db/seeds.rb +1 -0
  37. data/lib/panda_cms/engine.rb +44 -8
  38. data/lib/panda_cms/version.rb +1 -1
  39. metadata +89 -103
  40. data/app/javascript/panda_cms/panda_cms_editable.js +0 -248
@@ -0,0 +1,34 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Image < Base
5
+ def render
6
+ url = data["url"]
7
+ caption = sanitize(data["caption"])
8
+ with_border = data["withBorder"]
9
+ with_background = data["withBackground"]
10
+ stretched = data["stretched"]
11
+
12
+ css_classes = ["prose"]
13
+ css_classes << "border" if with_border
14
+ css_classes << "bg-gray-100" if with_background
15
+ css_classes << "w-full" if stretched
16
+
17
+ html_safe(<<~HTML)
18
+ <figure class="#{css_classes.join(" ")}">
19
+ <img src="#{url}" alt="#{caption}" />
20
+ #{caption_element(caption)}
21
+ </figure>
22
+ HTML
23
+ end
24
+
25
+ private
26
+
27
+ def caption_element(caption)
28
+ return "" if caption.blank?
29
+ "<figcaption>#{caption}</figcaption>"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class List < Base
5
+ def render
6
+ list_type = (data["style"] == "ordered") ? "ol" : "ul"
7
+ html_safe(
8
+ "<#{list_type}>" \
9
+ "#{render_items(data["items"])}" \
10
+ "</#{list_type}>"
11
+ )
12
+ end
13
+
14
+ private
15
+
16
+ def render_items(items)
17
+ items.map do |item|
18
+ content = item.is_a?(Hash) ? item["content"] : item
19
+ nested = (item.is_a?(Hash) && item["items"].present?) ? render_nested(item["items"]) : ""
20
+ "<li>#{sanitize(content)}#{nested}</li>"
21
+ end.join
22
+ end
23
+
24
+ def render_nested(items)
25
+ self.class.new({"items" => items, "style" => data["style"]}).render
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Paragraph < Base
5
+ def render
6
+ content = sanitize(data["text"])
7
+ return "" if content.blank?
8
+ html_safe("<p>#{content}</p>")
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Quote < Base
5
+ def render
6
+ text = sanitize(data["text"])
7
+ caption = sanitize(data["caption"])
8
+ alignment = data["alignment"] || "left"
9
+
10
+ html_safe(
11
+ "<figure class=\"text-#{alignment}\">" \
12
+ "<blockquote><p>#{text}</p></blockquote>" \
13
+ "#{caption_element(caption)}" \
14
+ "</figure>"
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def caption_element(caption)
21
+ return "" if caption.blank?
22
+ "<figcaption>#{caption}</figcaption>"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,48 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Table < Base
5
+ def render
6
+ content = data["content"]
7
+ with_headings = data["withHeadings"]
8
+
9
+ html_safe(<<~HTML)
10
+ <div class="overflow-x-auto">
11
+ <table class="min-w-full">
12
+ #{render_rows(content, with_headings)}
13
+ </table>
14
+ </div>
15
+ HTML
16
+ end
17
+
18
+ private
19
+
20
+ def render_rows(content, with_headings)
21
+ rows = []
22
+ index = 0
23
+
24
+ while index < content.length
25
+ rows << if index == 0 && with_headings
26
+ render_header_row(content[index])
27
+ else
28
+ render_data_row(content[index])
29
+ end
30
+ index += 1
31
+ end
32
+
33
+ rows.join("\n")
34
+ end
35
+
36
+ def render_header_row(row)
37
+ cells = row.map { |cell| "<th>#{sanitize(cell)}</th>" }
38
+ "<tr>#{cells.join}</tr>"
39
+ end
40
+
41
+ def render_data_row(row)
42
+ cells = row.map { |cell| "<td>#{sanitize(cell)}</td>" }
43
+ "<tr>#{cells.join}</tr>"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,120 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ class Renderer
4
+ attr_reader :content, :options, :custom_renderers, :cache_store
5
+
6
+ def initialize(content, options = {})
7
+ @content = content
8
+ @options = options
9
+ @custom_renderers = options.delete(:custom_renderers) || {}
10
+ @cache_store = options.delete(:cache_store) || Rails.cache
11
+ @validate_html = options.delete(:validate_html) || false
12
+ end
13
+
14
+ def render
15
+ return "" if content.nil? || !content.is_a?(Hash) || !content["blocks"]
16
+
17
+ blocks = remove_empty_paragraphs(content["blocks"])
18
+ rendered = blocks.map { |block| render_block_with_cache(block) }.join("\n")
19
+
20
+ @validate_html ? validate_html(rendered) : rendered
21
+ end
22
+
23
+ def section(blocks)
24
+ "<section class=\"content-section\">#{render_blocks(blocks)}</section>"
25
+ end
26
+
27
+ def article(blocks, title: nil)
28
+ content = []
29
+ content << "<h1>#{title}</h1>" if title
30
+ content << render_blocks(blocks)
31
+ "<article>#{content.join("\n")}</article>"
32
+ end
33
+
34
+ private
35
+
36
+ def render_blocks(blocks)
37
+ blocks.map { |block| render_block_with_cache(block) }.join("\n")
38
+ end
39
+
40
+ def validate_html(html)
41
+ # First check if we have matching numbers of opening and closing tags
42
+ opening_tags = html.scan(/<([a-z]+)[^>]*>/i)
43
+ closing_tags = html.scan(/<\/([a-z]+)>/i)
44
+
45
+ # Early return if tag counts don't match
46
+ return "" unless opening_tags.length == closing_tags.length
47
+
48
+ # Check tag order and nesting
49
+ stack = []
50
+ tag_pattern = /<\/?([a-z]+)[^>]*>/i
51
+ position = 0
52
+
53
+ while (match = html[position..].match(tag_pattern))
54
+ tag_name = match[1].downcase
55
+ is_closing = match[0].start_with?("</")
56
+
57
+ if is_closing
58
+ return "" if stack.pop != tag_name
59
+ else
60
+ stack.push(tag_name)
61
+ end
62
+
63
+ position += match.begin(0) + match[0].length
64
+ end
65
+
66
+ stack.empty? ? html : ""
67
+ end
68
+
69
+ def render_block_with_cache(block)
70
+ return "" if @validate_html && has_invalid_html?(block["data"])
71
+
72
+ cache_key = "editor_js_block/#{block["type"]}/#{Digest::MD5.hexdigest(block["data"].to_json)}"
73
+ cache_store.fetch(cache_key) do
74
+ renderer_for(block).render
75
+ end
76
+ end
77
+
78
+ def remove_empty_paragraphs(blocks)
79
+ blocks.reject do |block|
80
+ block["type"] == "paragraph" &&
81
+ block["data"]["text"].blank? &&
82
+ (blocks.last == block || next_block_is_empty?(blocks, block))
83
+ end
84
+ end
85
+
86
+ def next_block_is_empty?(blocks, current_block)
87
+ current_index = blocks.index(current_block)
88
+ next_block = blocks[current_index + 1]
89
+ next_block && next_block["type"] == "paragraph" && next_block["data"]["text"].blank?
90
+ end
91
+
92
+ def renderer_for(block)
93
+ if custom_renderers[block["type"]]
94
+ custom_renderers[block["type"]].new(block["data"], options)
95
+ else
96
+ default_renderer_for(block)
97
+ end
98
+ end
99
+
100
+ def default_renderer_for(block)
101
+ renderer_class = "PandaCms::EditorJs::Blocks::#{block["type"].classify}".constantize
102
+ renderer_class.new(block["data"], options)
103
+ rescue NameError
104
+ PandaCms::EditorJs::Blocks::Base.new(block["data"], options)
105
+ end
106
+
107
+ private
108
+
109
+ def has_invalid_html?(data)
110
+ data.values.any? do |value|
111
+ next unless value.is_a?(String)
112
+ opening_tags = value.scan(/<([a-z]+)[^>]*>/i)
113
+ closing_tags = value.scan(/<\/([a-z]+)>/i)
114
+ opening_tags.length != closing_tags.length ||
115
+ opening_tags.map(&:first) != closing_tags.map(&:first)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -1,5 +1,3 @@
1
- require "redcarpet"
2
-
3
1
  module PandaCms
4
2
  class BlockContent < ApplicationRecord
5
3
  self.table_name = "panda_cms_block_contents"
@@ -12,5 +10,17 @@ module PandaCms
12
10
  belongs_to :block, foreign_key: :panda_cms_block_id, class_name: "PandaCms::Block", inverse_of: :block_contents, optional: false
13
11
 
14
12
  validates :block, presence: true, uniqueness: {scope: :page}
13
+
14
+ before_save :generate_cache
15
+
16
+ private
17
+
18
+ def generate_cache
19
+ self.cached_content = if content.is_a?(Hash) && content.dig("source") == "editorJS"
20
+ EditorJs::Renderer.new(content).render
21
+ else
22
+ content
23
+ end
24
+ end
15
25
  end
16
26
  end
@@ -16,13 +16,14 @@
16
16
  <span class="absolute right-0"><%= render PandaCms::Admin::ButtonComponent.new(text: "Save Changes", action: :save_inactive, icon: "check", link: "#", size: :regular, id: "saveEditableButton") %></span>
17
17
  </div>
18
18
  </div>
19
- <iframe id="editablePageFrame" src="<%= page.path %>?embed_id=<%= page.id %>" class="p-0 m-0 w-full h-full border border-slate-200"></iframe>
20
- <% end %>
21
- <% content_for :head do %>
22
- <%#= javascript_include_tag "panda_cms_editable", "data-turbo-track": "reload", defer: true %>
23
- <!-- <script>
24
- document.addEventListener("DOMContentLoaded", function() {
25
- const editable = new PandaCmsEditableController("<%= page.id %>", document.getElementById("editablePageFrame"));
26
- });
27
- </script> -->
19
+ <%= content_tag :iframe, nil,
20
+ src: "#{page.path}?embed_id=#{page.id}",
21
+ class: "p-0 m-0 w-full h-full border border-slate-200",
22
+ id: "editablePageFrame",
23
+ data: {
24
+ controller: "editor",
25
+ editor_page_id_value: @page.id,
26
+ editor_admin_path_value: "#{admin_dashboard_url}",
27
+ editor_autosave_value: false
28
+ } %>
28
29
  <% end %>
@@ -4,11 +4,10 @@
4
4
  <title><%= title_tag %></title>
5
5
  <%= csrf_meta_tags %>
6
6
  <%= csp_meta_tag %>
7
- <script src="https://kit.fontawesome.com/7835d81e75.js" crossorigin="anonymous"></script>
8
- <script async src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js" crossorigin="anonymous"></script>
7
+ <script src="https://kit.fontawesome.com/7835d81e75.js" defer="true" crossorigin="anonymous"></script>
8
+ <script async src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js" defer="true" crossorigin="anonymous"></script>
9
9
  <%= stylesheet_link_tag "panda_cms", "data-turbo-track": "reload", media: "all" %>
10
10
  <%= render "panda_cms/shared/importmap" %>
11
- <%#= turbo_refreshes_with method: :morph, scroll: :preserve %>
12
11
  <%= render "panda_cms/shared/favicons" %>
13
12
  <%= yield :head %>
14
13
  </head>
@@ -4,15 +4,25 @@
4
4
  # This ended up being the best solution to not drag in the importmap from the host app when we're editing the site
5
5
  JSON.pretty_generate({
6
6
  imports: {
7
+ # Main loader
7
8
  "application_panda_cms": asset_path("panda_cms/application_panda_cms.js"),
9
+ # Vendored
8
10
  "@hotwired/turbo": asset_path("panda_cms/@hotwired--turbo.js"),
9
11
  "@hotwired/stimulus": asset_path("panda_cms/@hotwired--stimulus.js"),
10
12
  "@hotwired/stimulus-loading": asset_path("stimulus-loading.js"),
13
+ "@editorjs/editorjs": asset_path("panda_cms/@editorjs--editorjs.js"),
11
14
  "tailwindcss-stimulus-components": asset_path("panda_cms/tailwindcss-stimulus-components.js"),
12
- "controllers/dashboard_controller": asset_path("panda_cms/controllers/dashboard_controller.js"),
13
- "controllers/slug_controller": asset_path("panda_cms/controllers/slug_controller.js"),
15
+ # Our controllers
16
+ "panda_cms_controllers/dashboard_controller": asset_path("panda_cms/controllers/dashboard_controller.js"),
17
+ "panda_cms_controllers/slug_controller": asset_path("panda_cms/controllers/slug_controller.js"),
14
18
  # Add other controllers here
15
- "controllers": asset_path("panda_cms/controllers/index.js")
19
+ # Our page editor
20
+ "panda_cms_controllers/editor_controller": asset_path("panda_cms/controllers/editor_controller.js"),
21
+ "panda_cms_editor/plain_text_editor": asset_path("panda_cms/editor/plain_text_editor.js"),
22
+ "panda_cms_editor/rich_text_editor": asset_path("panda_cms/editor/rich_text_editor.js"),
23
+ "panda_cms_editor/resource_loader": asset_path("panda_cms/editor/resource_loader.js"),
24
+ # Main controller loader
25
+ "controllers": asset_path("panda_cms/controllers/index.js"),
16
26
  }
17
27
  }).html_safe
18
28
  %>
data/config/importmap.rb CHANGED
@@ -5,8 +5,10 @@ pin "@rails/actioncable/src", to: "@rails--actioncable--src.js", preload: true #
5
5
  pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
6
6
  pin "@hotwired/stimulus-loading", to: "panda_cms/stimulus-loading.js", preload: true
7
7
  pin "tailwindcss-stimulus-components" # @6.1.2
8
+ pin "@editorjs/editorjs", to: "@editorjs--editorjs.js" # @2.30.6
8
9
 
9
10
  # pin "@rails/activestorage", to: "@rails--activestorage.js" # @7.2.100
10
11
  # pin "@rails/actioncable", to: "@rails--actioncable.js" # @7.2.100
11
12
 
12
- pin_all_from PandaCms::Engine.root.join("app/javascript/panda_cms/controllers"), under: "controllers", to: "panda_cms/controllers"
13
+ pin_all_from PandaCms::Engine.root.join("app/javascript/panda_cms/controllers"), under: "panda_cms_controllers", to: "panda_cms/controllers"
14
+ pin_all_from PandaCms::Engine.root.join("app/javascript/panda_cms/editor"), under: "editor", to: "panda_cms/editor"
@@ -1,7 +1,9 @@
1
1
  class AddStatusToPandaCmsPages < ActiveRecord::Migration[7.1]
2
2
  def change
3
- create_enum :panda_cms_page_status, ["active", "draft", "hidden", "archived"]
4
- add_column :panda_cms_pages, :status, :panda_cms_page_status, default: "active", null: false
5
- add_index :panda_cms_pages, :status
3
+ unless column_exists?(:panda_cms_pages, :status)
4
+ create_enum :panda_cms_page_status, ["active", "draft", "hidden", "archived"]
5
+ add_column :panda_cms_pages, :status, :panda_cms_page_status, default: "active", null: false
6
+ add_index :panda_cms_pages, :status
7
+ end
6
8
  end
7
9
  end
@@ -0,0 +1,5 @@
1
+ class AddCachedContentToPandaCmsBlockContents < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :panda_cms_block_contents, :cached_content, :jsonb
4
+ end
5
+ end
data/db/seeds.rb CHANGED
@@ -2,3 +2,4 @@ generator = PandaCms::DemoSiteGenerator.new
2
2
  generator.create_templates
3
3
  generator.create_pages
4
4
  generator.create_menus
5
+ PandaCms::Template.generate_missing_blocks
@@ -1,6 +1,7 @@
1
1
  require "importmap-rails"
2
2
  require "turbo-rails"
3
3
  require "stimulus-rails"
4
+ require "view_component"
4
5
 
5
6
  module PandaCms
6
7
  class Engine < ::Rails::Engine
@@ -31,7 +32,7 @@ module PandaCms
31
32
  )
32
33
 
33
34
  # Custom error handling
34
- config.exceptions_app = PandaCms::ExceptionsApp.new(exceptions_app: routes)
35
+ # config.exceptions_app = PandaCms::ExceptionsApp.new(exceptions_app: routes)
35
36
 
36
37
  initializer "panda_cms.assets" do |app|
37
38
  if Rails.configuration.respond_to?(:assets)
@@ -55,6 +56,9 @@ module PandaCms
55
56
  get "/*path", to: "panda_cms/pages#show", as: :panda_cms_page
56
57
  root to: "panda_cms/pages#root"
57
58
  end
59
+
60
+ # Ensure we don't have any missing blocks from new templates
61
+ PandaCms::Template.generate_missing_blocks
58
62
  end
59
63
 
60
64
  # Add the migrations to the main app
@@ -66,15 +70,43 @@ module PandaCms
66
70
  end
67
71
  end
68
72
 
73
+ initializer "#{engine_name}.backtrace_cleaner" do |app|
74
+ engine_root_regex = Regexp.escape(root.to_s + File::SEPARATOR)
75
+
76
+ # Clean those ERB lines, we don't need the internal autogenerated
77
+ # ERB method, what we do need (line number in ERB file) is already there
78
+ Rails.backtrace_cleaner.add_filter do |line|
79
+ line.sub(/(\.erb:\d+):in `__.*$/, "\\1")
80
+ end
81
+
82
+ # Remove our own engine's path prefix, even if it's
83
+ # being used from a local path rather than the gem directory.
84
+ Rails.backtrace_cleaner.add_filter do |line|
85
+ line.sub(/^#{engine_root_regex}/, "#{engine_name} ")
86
+ end
87
+
88
+ # Keep Umlaut's own stacktrace in the backtrace -- we have to remove Rails
89
+ # silencers and re-add them how we want.
90
+ Rails.backtrace_cleaner.remove_silencers!
91
+
92
+ # Silence what Rails silenced, UNLESS it looks like
93
+ # it's from Umlaut engine
94
+ Rails.backtrace_cleaner.add_silencer do |line|
95
+ (line !~ Rails::BacktraceCleaner::APP_DIRS_PATTERN) &&
96
+ (line !~ /^#{engine_root_regex}/) &&
97
+ (line !~ /^#{engine_name} /)
98
+ end
99
+ end
100
+
69
101
  # Set up ViewComponent and Lookbook
70
102
  # config.view_component.component_parent_class = "PandaCms::BaseComponent"
71
- config.view_component.view_component_path = PandaCms::Engine.root.join("lib/components").to_s
72
- config.eager_load_paths << PandaCms::Engine.root.join("lib/components").to_s
73
- config.view_component.generate.sidecar = true
74
- config.view_component.generate.preview = true
75
- config.view_component.preview_paths ||= []
76
- config.view_component.preview_paths << PandaCms::Engine.root.join("lib/component_previews").to_s
77
- config.view_component.generate.preview_path = "lib/component_previews"
103
+ # config.view_component.view_component_path = PandaCms::Engine.root.join("lib/components").to_s
104
+ # config.eager_load_paths << PandaCms::Engine.root.join("lib/components").to_s
105
+ # config.view_component.generate.sidecar = true
106
+ # config.view_component.generate.preview = true
107
+ # config.view_component.preview_paths ||= []
108
+ # config.view_component.preview_paths << PandaCms::Engine.root.join("lib/component_previews").to_s
109
+ # config.view_component.generate.preview_path = "lib/component_previews"
78
110
 
79
111
  # Set up authentication
80
112
  initializer "panda_cms.omniauth", before: "omniauth" do |app|
@@ -177,4 +209,8 @@ module PandaCms
177
209
  end
178
210
  end
179
211
  end
212
+
213
+ class MissingBlockError < StandardError; end
214
+
215
+ class BlockError < StandardError; end
180
216
  end
@@ -1,3 +1,3 @@
1
1
  module PandaCms
2
- VERSION = "0.6.0"
2
+ VERSION = "0.6.2"
3
3
  end