panda-cms 0.10.0 → 0.10.3

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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -11
  3. data/app/assets/tailwind/panda/cms/_application.css +1 -0
  4. data/app/components/panda/cms/admin/popular_pages_component.rb +62 -0
  5. data/app/components/panda/cms/code_component.rb +46 -9
  6. data/app/components/panda/cms/menu_component.rb +18 -5
  7. data/app/components/panda/cms/page_menu_component.rb +9 -1
  8. data/app/components/panda/cms/rich_text_component.rb +49 -17
  9. data/app/components/panda/cms/text_component.rb +46 -14
  10. data/app/controllers/panda/cms/admin/menus_controller.rb +2 -2
  11. data/app/controllers/panda/cms/admin/pages_controller.rb +6 -2
  12. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  13. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  14. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  15. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  16. data/app/helpers/panda/cms/application_helper.rb +2 -3
  17. data/app/helpers/panda/cms/asset_helper.rb +14 -72
  18. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  19. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  20. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +4 -0
  21. data/app/javascript/panda/cms/controllers/editor_form_controller.js +3 -3
  22. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +35 -8
  23. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  24. data/app/javascript/panda/cms/controllers/index.js +6 -0
  25. data/app/javascript/panda/cms/controllers/menu_form_controller.js +14 -1
  26. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  27. data/app/javascript/panda/cms/stimulus-loading.js +2 -1
  28. data/app/jobs/panda/cms/record_visit_job.rb +2 -1
  29. data/app/models/panda/cms/menu.rb +12 -0
  30. data/app/models/panda/cms/page.rb +106 -0
  31. data/app/models/panda/cms/post.rb +97 -0
  32. data/app/models/panda/cms/visit.rb +16 -1
  33. data/app/services/panda/social/instagram_feed_service.rb +54 -54
  34. data/app/views/layouts/homepage.html.erb +1 -4
  35. data/app/views/layouts/page.html.erb +1 -4
  36. data/app/views/panda/cms/admin/dashboard/show.html.erb +11 -4
  37. data/app/views/panda/cms/admin/files/index.html.erb +1 -1
  38. data/app/views/panda/cms/admin/forms/new.html.erb +1 -1
  39. data/app/views/panda/cms/admin/forms/show.html.erb +3 -3
  40. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +3 -3
  41. data/app/views/panda/cms/admin/menus/edit.html.erb +12 -14
  42. data/app/views/panda/cms/admin/menus/index.html.erb +1 -1
  43. data/app/views/panda/cms/admin/menus/new.html.erb +6 -8
  44. data/app/views/panda/cms/admin/pages/edit.html.erb +213 -20
  45. data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
  46. data/app/views/panda/cms/admin/posts/_form.html.erb +47 -8
  47. data/app/views/panda/cms/admin/posts/edit.html.erb +1 -1
  48. data/app/views/panda/cms/admin/posts/index.html.erb +4 -4
  49. data/app/views/panda/cms/shared/_favicons.html.erb +7 -7
  50. data/app/views/shared/_header.html.erb +1 -4
  51. data/config/brakeman.ignore +38 -0
  52. data/config/importmap.rb +7 -6
  53. data/config/initializers/groupdate.rb +5 -0
  54. data/config/locales/en.yml +42 -2
  55. data/config/routes.rb +1 -1
  56. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +0 -10
  57. data/db/migrate/20240316230706_add_nested_to_panda_cms_menu_items.rb +0 -6
  58. data/db/migrate/20240317230622_create_panda_cms_visits.rb +1 -1
  59. data/db/migrate/20240805121123_create_panda_cms_posts.rb +1 -1
  60. data/db/migrate/20240806112735_fix_panda_cms_visits_column_names.rb +1 -1
  61. data/db/migrate/20240923234535_add_depth_to_panda_cms_menus.rb +0 -6
  62. data/db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb +1 -3
  63. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  64. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  65. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  66. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  67. data/db/migrate/20251117234530_add_index_to_visited_at_on_panda_cms_visits.rb +7 -0
  68. data/db/migrate/20251118015100_backfill_visited_at_for_existing_visits.rb +17 -0
  69. data/db/seeds.rb +5 -0
  70. data/lib/panda/cms/asset_loader.rb +42 -78
  71. data/lib/panda/cms/bulk_editor.rb +288 -12
  72. data/lib/panda/cms/engine/asset_config.rb +49 -0
  73. data/lib/panda/cms/engine/autoload_config.rb +37 -0
  74. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  75. data/lib/panda/cms/engine/core_config.rb +106 -0
  76. data/lib/panda/cms/engine/helper_config.rb +20 -0
  77. data/lib/panda/cms/engine/route_config.rb +33 -0
  78. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  79. data/lib/panda/cms/engine.rb +32 -228
  80. data/lib/{panda-cms → panda/cms}/version.rb +1 -1
  81. data/lib/panda/cms.rb +12 -0
  82. data/lib/panda-cms.rb +24 -3
  83. data/lib/tasks/ci.rake +0 -0
  84. metadata +32 -67
  85. data/app/assets/builds/panda.cms.css +0 -2754
  86. data/app/assets/stylesheets/panda/cms/application.tailwind.css +0 -162
  87. data/app/assets/stylesheets/panda/cms/editor.css +0 -120
  88. data/app/assets/tailwind/application.css +0 -178
  89. data/app/assets/tailwind/tailwind.config.js +0 -15
  90. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  91. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  92. data/config/initializers/inflections.rb +0 -5
  93. data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +0 -31
  94. data/db/migrate/20240317010532_create_panda_cms_users.rb +0 -14
  95. data/db/migrate/20240324205703_create_active_storage_tables.active_storage.rb +0 -61
  96. data/db/migrate/20240408084718_default_panda_cms_users_admin_to_false.rb +0 -7
  97. data/db/migrate/20240701225422_add_service_name_to_active_storage_blobs.active_storage.rb +0 -24
  98. data/db/migrate/20240701225423_create_active_storage_variant_records.active_storage.rb +0 -30
  99. data/db/migrate/20240701225424_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +0 -10
  100. data/db/migrate/20241119214548_convert_post_content_to_editor_js.rb +0 -37
  101. data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +0 -113
  102. data/lib/generators/panda/cms/install_generator.rb +0 -28
  103. data/lib/tasks/assets.rake +0 -540
  104. data/public/panda-cms-assets/editor-js/core/editorjs.min.js +0 -83
  105. data/public/panda-cms-assets/editor-js/plugins/embed.min.js +0 -2
  106. data/public/panda-cms-assets/editor-js/plugins/header.min.js +0 -9
  107. data/public/panda-cms-assets/editor-js/plugins/nested-list.min.js +0 -2
  108. data/public/panda-cms-assets/editor-js/plugins/paragraph.min.js +0 -9
  109. data/public/panda-cms-assets/editor-js/plugins/quote.min.js +0 -2
  110. data/public/panda-cms-assets/editor-js/plugins/simple-image.min.js +0 -2
  111. data/public/panda-cms-assets/editor-js/plugins/table.min.js +0 -2
  112. data/public/panda-cms-assets/favicons/android-chrome-192x192.png +0 -0
  113. data/public/panda-cms-assets/favicons/android-chrome-512x512.png +0 -0
  114. data/public/panda-cms-assets/favicons/apple-touch-icon.png +0 -0
  115. data/public/panda-cms-assets/favicons/browserconfig.xml +0 -9
  116. data/public/panda-cms-assets/favicons/favicon-16x16.png +0 -0
  117. data/public/panda-cms-assets/favicons/favicon-32x32.png +0 -0
  118. data/public/panda-cms-assets/favicons/favicon.ico +0 -0
  119. data/public/panda-cms-assets/favicons/mstile-150x150.png +0 -0
  120. data/public/panda-cms-assets/favicons/safari-pinned-tab.svg +0 -61
  121. data/public/panda-cms-assets/favicons/site.webmanifest +0 -14
  122. data/public/panda-cms-assets/manifest.json +0 -20
  123. data/public/panda-cms-assets/panda-cms-0.7.4.css +0 -26
  124. data/public/panda-cms-assets/panda-cms-0.7.4.js +0 -150
  125. data/public/panda-cms-assets/panda-logo-screenprint.png +0 -0
  126. data/public/panda-cms-assets/panda-nav.png +0 -0
  127. data/public/panda-cms-assets/rich_text_editor.css +0 -568
  128. /data/db/migrate/{20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb → 20251105000001_add_pending_review_status_to_pages_and_posts.rb} +0 -0
@@ -3,23 +3,146 @@
3
3
  module Panda
4
4
  module CMS
5
5
  class FormSubmissionsController < ApplicationController
6
- invisible_captcha only: [:create]
6
+ # Spam protection - invisible honeypot field
7
+ invisible_captcha only: [:create], on_spam: :log_spam
7
8
 
8
- def create
9
- vars = params.except(:authenticity_token, :controller, :action, :id)
9
+ # Rate limiting to prevent spam
10
+ before_action :check_rate_limit, only: [:create]
10
11
 
12
+ def create
11
13
  form = Panda::CMS::Form.find(params[:id])
12
- form_submission = Panda::CMS::FormSubmission.create(form_id: params[:id], data: vars.to_unsafe_h)
13
- form.update(submission_count: form.submission_count + 1)
14
14
 
15
- Panda::CMS::FormMailer.notification_email(form: form, form_submission: form_submission).deliver_now
15
+ # Additional spam checks
16
+ if looks_like_spam?(params)
17
+ log_spam_attempt(form, "content")
18
+ redirect_to_fallback(form, spam: true)
19
+ return
20
+ end
21
+
22
+ # Timing-based spam detection (honeypot timing)
23
+ if submitted_too_quickly?(params)
24
+ log_spam_attempt(form, "timing")
25
+ redirect_to_fallback(form, spam: true)
26
+ return
27
+ end
28
+
29
+ # Clean parameters - exclude system params and honeypot field
30
+ vars = params.except(:authenticity_token, :controller, :action, :id, :_form_timestamp, :spinner)
31
+
32
+ # Create submission
33
+ form_submission = Panda::CMS::FormSubmission.create!(
34
+ form_id: form.id,
35
+ data: vars.to_unsafe_h,
36
+ ip_address: request.remote_ip,
37
+ user_agent: request.user_agent
38
+ )
39
+
40
+ # Update submission count
41
+ form.increment!(:submission_count)
42
+
43
+ # Send notification email (in background if possible)
44
+ begin
45
+ Panda::CMS::FormMailer.notification_email(form: form, form_submission: form_submission).deliver_now
46
+ rescue => e
47
+ Rails.logger&.error "Failed to send form notification email: #{e.message}"
48
+ # Don't fail the submission if email fails
49
+ end
50
+
51
+ redirect_to_fallback(form, success: true)
52
+ rescue ActiveRecord::RecordInvalid => e
53
+ Rails.logger&.error "Form submission validation failed: #{e.message}"
54
+ redirect_to_fallback(form, error: true)
55
+ end
56
+
57
+ private
58
+
59
+ # Check for basic spam indicators
60
+ def looks_like_spam?(params)
61
+ # Check for too many URLs in message fields
62
+ message_fields = params.values.select { |v| v.is_a?(String) && v.length > 20 }
63
+ message_fields.any? { |field| field.scan(/https?:\/\//).length > 3 }
64
+ end
65
+
66
+ # Timing-based spam detection
67
+ # Rejects submissions that are too fast (< 3 seconds) or too stale (> 24 hours)
68
+ def submitted_too_quickly?(params)
69
+ return false unless params[:_form_timestamp].present?
70
+
71
+ begin
72
+ form_loaded_at = Time.zone.at(params[:_form_timestamp].to_i)
73
+ time_elapsed = Time.current - form_loaded_at
74
+
75
+ # Too fast - likely a bot (< 3 seconds)
76
+ if time_elapsed < 3.seconds
77
+ Rails.logger&.warn "Form submitted too quickly: #{time_elapsed.round(2)}s from IP: #{request.remote_ip}"
78
+ return true
79
+ end
80
+
81
+ # Too stale - form held too long without interaction (> 24 hours)
82
+ if time_elapsed > 24.hours
83
+ Rails.logger&.warn "Form submission too old: #{(time_elapsed / 1.hour).round(1)}h from IP: #{request.remote_ip}"
84
+ return true
85
+ end
86
+
87
+ false
88
+ rescue ArgumentError, TypeError => e
89
+ Rails.logger&.warn "Invalid form timestamp from IP #{request.remote_ip}: #{e.message}"
90
+ # Don't reject on invalid timestamp - might be legitimate user with modified form
91
+ false
92
+ end
93
+ end
94
+
95
+ # Rate limiting - max 3 submissions per IP per 5 minutes
96
+ def check_rate_limit
97
+ cache_key = "form_submission_rate_limit:#{request.remote_ip}"
98
+ count = Rails.cache.read(cache_key) || 0
99
+
100
+ if count >= 3
101
+ Rails.logger&.warn "Rate limit exceeded for IP: #{request.remote_ip}"
102
+ render plain: "Too many requests. Please try again later.", status: :too_many_requests
103
+ return
104
+ end
105
+
106
+ Rails.cache.write(cache_key, count + 1, expires_in: 5.minutes)
107
+ end
108
+
109
+ # Log spam attempt with reason
110
+ def log_spam_attempt(form, reason)
111
+ Rails.logger&.warn "Spam detected (#{reason}) for form #{form.id} from IP: #{request.remote_ip}"
112
+ end
113
+
114
+ # Callback for invisible_captcha spam detection
115
+ def log_spam
116
+ Rails.logger&.warn "Invisible captcha triggered from IP: #{request.remote_ip}"
117
+ end
118
+
119
+ # Safe redirect that works in engine context
120
+ def redirect_to_fallback(form, success: false, spam: false, error: false)
121
+ fallback = "/"
16
122
 
17
- if (completion_path = form&.completion_path)
18
- redirect_to completion_path
123
+ if spam
124
+ # Redirect to same page to appear successful (don't tell spammers)
125
+ redirect_back(fallback_location: fallback, allow_other_host: false)
126
+ elsif success && form.completion_path.present?
127
+ # Redirect to custom completion path
128
+ redirect_to form.completion_path, notice: "Thank you for your submission!"
129
+ elsif success
130
+ # Redirect back to referring page with success message
131
+ redirect_back(
132
+ fallback_location: fallback,
133
+ notice: "Thank you for your submission!",
134
+ allow_other_host: false
135
+ )
136
+ elsif error
137
+ # Redirect back with error message
138
+ redirect_back(
139
+ fallback_location: fallback,
140
+ alert: "There was an error submitting your form. Please try again.",
141
+ allow_other_host: false
142
+ )
19
143
  else
20
- # TODO: This isn't a great fallback, we should do something nice here...
21
- # Perhaps a simple JS alert when sent?
22
- redirect_to "/"
144
+ # Default fallback
145
+ redirect_back(fallback_location: fallback, allow_other_host: false)
23
146
  end
24
147
  end
25
148
  end
@@ -16,9 +16,9 @@ module Panda
16
16
 
17
17
  def show
18
18
  page = if @overrides&.dig(:page_path_match)
19
- Panda::CMS::Page.includes(:template).find_by(path: @overrides[:page_path_match])
19
+ Panda::CMS::Page.includes(:template, :block_contents).find_by(path: @overrides[:page_path_match])
20
20
  else
21
- Panda::CMS::Page.includes(:template).find_by(path: "/#{params[:path]}")
21
+ Panda::CMS::Page.includes(:template, :block_contents).find_by(path: "/#{params[:path]}")
22
22
  end
23
23
 
24
24
  Panda::CMS::Current.page = page || Panda::CMS::Page.find_by(path: "/404")
@@ -31,6 +31,11 @@ module Panda
31
31
  render file: "#{Rails.root}/public/404.html", layout: false, status: :not_found and return
32
32
  end
33
33
 
34
+ # HTTP caching: Send ETag and Last-Modified headers for efficient caching
35
+ # Use cached_last_updated_at which includes block content updates
36
+ # Returns 304 Not Modified if client's cached version is still valid
37
+ fresh_when(page, last_modified: page.last_updated_at, public: true)
38
+
34
39
  template_vars = {
35
40
  page: page,
36
41
  title: Panda::CMS::Current.page&.title || Panda::CMS.config.title
@@ -7,6 +7,12 @@ module Panda
7
7
  # inside a /panda/cms/posts/... structure in the application
8
8
  def index
9
9
  @posts = Panda::CMS::Post.includes(:author).order(published_at: :desc)
10
+
11
+ # HTTP caching: Use the most recent post's updated_at for conditional requests
12
+ # Returns 304 Not Modified if no posts have changed since client's last request
13
+ latest_post_timestamp = @posts.maximum(:updated_at) || Time.current
14
+ fresh_when(etag: [@posts.to_a, latest_post_timestamp], last_modified: latest_post_timestamp, public: true)
15
+
10
16
  render inline: "", layout: Panda::CMS.config.posts[:layouts][:index]
11
17
  end
12
18
 
@@ -19,6 +25,11 @@ module Panda
19
25
  # For non-date URLs
20
26
  Panda::CMS::Post.find_by!(slug: "/#{params[:slug]}")
21
27
  end
28
+
29
+ # HTTP caching: Send ETag and Last-Modified headers for individual posts
30
+ # Returns 304 Not Modified if client's cached version is still valid
31
+ fresh_when(@post, last_modified: @post.updated_at, public: true)
32
+
22
33
  render inline: "", layout: Panda::CMS.config.posts[:layouts][:show]
23
34
  end
24
35
 
@@ -30,6 +41,11 @@ module Panda
30
41
  .includes(:author)
31
42
  .ordered
32
43
 
44
+ # HTTP caching: Use the most recent post in this month for conditional requests
45
+ # Returns 304 Not Modified if no posts in this month have changed
46
+ latest_month_timestamp = @posts.maximum(:updated_at) || @month
47
+ fresh_when(etag: [@posts.to_a, @month], last_modified: latest_month_timestamp, public: true)
48
+
33
49
  render inline: "", layout: Panda::CMS.config.posts[:layouts][:by_month]
34
50
  end
35
51
  end
@@ -2,8 +2,7 @@ module Panda
2
2
  module CMS
3
3
  module ApplicationHelper
4
4
  #
5
- # Helper method to render a ViewComponent
6
- # @see ViewComponent::Rendering#render
5
+ # Helper method to render a component
7
6
  # @usage <%= component "example", title: "Hello World!" %>
8
7
  #
9
8
  def component(name, *, **, &)
@@ -60,7 +59,7 @@ module Panda
60
59
 
61
60
  def panda_cms_form_with(**options, &)
62
61
  options[:builder] = Panda::Core::FormBuilder
63
- options[:class] = ["block visible p-6 bg-mid/5 rounded-lg border-mid border", options[:class]].compact.join(" ")
62
+ options[:class] = ["block visible px-4 sm:px-6 pt-4", options[:class]].compact.join(" ")
64
63
  form_with(**options, &)
65
64
  end
66
65
 
@@ -24,51 +24,9 @@ module Panda
24
24
  end
25
25
 
26
26
  # Include only Panda CMS JavaScript
27
- def panda_cms_javascript
28
- js_url = Panda::CMS::AssetLoader.javascript_url
29
- return "" unless js_url
30
-
31
- if Panda::CMS::AssetLoader.use_github_assets?
32
- # GitHub-hosted assets with integrity check
33
- version = Panda::CMS::AssetLoader.send(:asset_version)
34
- integrity = asset_integrity(version, "panda-cms-#{version}.js")
35
-
36
- tag_options = {
37
- src: js_url
38
- }
39
- # In CI environment, don't use defer to ensure immediate execution
40
- tag_options[:defer] = true unless ENV["GITHUB_ACTIONS"] == "true"
41
- # Standalone bundles should NOT use type: "module" - they're regular scripts
42
- # Only use type: "module" for importmap/ES module assets
43
- if !js_url.include?("panda-cms-assets")
44
- tag_options[:type] = "module"
45
- end
46
- tag_options[:integrity] = integrity if integrity
47
- tag_options[:crossorigin] = "anonymous" if integrity
48
-
49
- content_tag(:script, "", tag_options)
50
- elsif js_url.include?("panda-cms-assets")
51
- # Development assets - check if it's a standalone bundle or importmap
52
- defer_option = (ENV["GITHUB_ACTIONS"] == "true") ? {} : {defer: true}
53
- javascript_include_tag(js_url, **defer_option)
54
- # Standalone bundle - don't use type: "module"
55
- else
56
- # Development mode - Load JavaScript with import map
57
- # Files are served by Rack::Static middleware from engine's app/javascript
58
- importmap_html = <<~HTML
59
- <script type="importmap">
60
- {
61
- "imports": {
62
- "@hotwired/stimulus": "/panda/core/vendor/@hotwired--stimulus.js",
63
- "@hotwired/turbo": "/panda/core/vendor/@hotwired--turbo.js"
64
- }
65
- }
66
- </script>
67
- <script type="module" src="/panda/cms/application_panda_cms.js"></script>
68
- HTML
69
- importmap_html.html_safe
70
- end
71
- end
27
+ #
28
+ # Delegates to Core's helper which automatically includes all registered modules.
29
+ alias_method :panda_cms_javascript, :panda_core_javascript
72
30
 
73
31
  # Include only Panda CMS CSS
74
32
  def panda_cms_stylesheet
@@ -117,39 +75,16 @@ module Panda
117
75
  version = Panda::CMS::VERSION
118
76
  js_url = Panda::CMS::AssetLoader.javascript_url
119
77
  css_url = Panda::CMS::AssetLoader.css_url
120
- using_github = Panda::CMS::AssetLoader.use_github_assets?
121
- compiled_available = Panda::CMS::AssetLoader.send(:compiled_assets_available?)
122
-
123
- # Additional CI debugging
124
- asset_file_exists = js_url && File.exist?(Rails.root.join("public#{js_url}"))
125
- ci_env = ENV["GITHUB_ACTIONS"] == "true"
126
-
127
- # Check what script tag will be generated
128
- script_tag_preview = if using_github
129
- tag_options = {src: js_url}
130
- tag_options[:defer] = true unless ci_env
131
- if !js_url.include?("panda-cms-assets")
132
- tag_options[:type] = "module"
133
- end
134
- "Script tag: <script#{tag_options.map { |k, v| (v == true) ? " #{k}" : " #{k}=\"#{v}\"" }.join}></script>"
135
- else
136
- "Using development assets"
137
- end
78
+ Panda::CMS::AssetLoader.use_github_assets?
138
79
 
139
80
  debug_info = [
140
81
  "<!-- Panda CMS Asset Debug Info -->",
141
82
  "<!-- Version: #{version} -->",
142
- "<!-- Using GitHub assets: #{using_github} -->",
143
- "<!-- Compiled assets available: #{compiled_available} -->",
83
+ "<!-- Using importmaps: true (no compilation) -->",
144
84
  "<!-- JavaScript URL: #{js_url} -->",
145
- "<!-- CSS URL: #{css_url || "none"} -->",
85
+ "<!-- CSS URL: #{css_url || "CSS from panda-core"} -->",
146
86
  "<!-- Rails environment: #{Rails.env} -->",
147
- "<!-- Asset file exists: #{asset_file_exists} -->",
148
- "<!-- Rails root: #{Rails.root} -->",
149
- "<!-- CI environment: #{ci_env} -->",
150
- "<!-- #{script_tag_preview} -->",
151
- "<!-- Params embed_id: #{params[:embed_id] if respond_to?(:params)} -->",
152
- "<!-- Compiled at: #{Time.now.utc.iso8601} -->"
87
+ "<!-- Rails root: #{Rails.root} -->"
153
88
  ]
154
89
 
155
90
  debug_info.join("\n").html_safe
@@ -194,6 +129,13 @@ module Panda
194
129
  ].join("\n").html_safe
195
130
  end
196
131
 
132
+ # Returns asset HTML for injection into iframe
133
+ # Used by editor_iframe_controller to inject assets dynamically
134
+ # Returns the raw HTML string (not JSON-encoded)
135
+ def panda_cms_injectable_assets
136
+ panda_cms_complete_assets.to_s
137
+ end
138
+
197
139
  private
198
140
 
199
141
  def asset_integrity(version, filename)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module CMS
5
+ module FormsHelper
6
+ # Generates a hidden timing field for spam protection
7
+ # This should be included in all forms that submit to Panda::CMS::FormSubmissionsController
8
+ #
9
+ # @example In your form
10
+ # <%= form_with url: form_submissions_path(form.id), method: :post do |f| %>
11
+ # <%= panda_cms_form_timestamp %>
12
+ # <%= f.text_field :name %>
13
+ # <%= f.submit "Submit" %>
14
+ # <% end %>
15
+ #
16
+ # @return [String] HTML hidden input with current timestamp
17
+ def panda_cms_form_timestamp
18
+ hidden_field_tag "_form_timestamp", Time.current.to_i
19
+ end
20
+
21
+ # Generates a complete spam-protected form wrapper
22
+ # Includes timing protection and invisible captcha honeypot
23
+ #
24
+ # @param form [Panda::CMS::Form] The form model
25
+ # @param options [Hash] Additional options for form_with
26
+ # @yield [FormBuilder] The form builder
27
+ #
28
+ # @example
29
+ # <%= panda_cms_protected_form(form) do |f| %>
30
+ # <%= f.text_field :name %>
31
+ # <%= f.email_field :email %>
32
+ # <%= f.text_area :message %>
33
+ # <%= f.submit "Send Message" %>
34
+ # <% end %>
35
+ def panda_cms_protected_form(form, options = {}, &block)
36
+ default_options = {
37
+ url: "/forms/#{form.id}",
38
+ method: :post,
39
+ data: {turbo: false}
40
+ }
41
+
42
+ form_with(**default_options.merge(options)) do |f|
43
+ concat panda_cms_form_timestamp
44
+ concat invisible_captcha_field
45
+ yield f
46
+ end
47
+ end
48
+
49
+ # Generates the invisible captcha honeypot field
50
+ # This is a hidden field that bots typically fill out but humans don't
51
+ #
52
+ # @return [String] HTML for invisible captcha field
53
+ def invisible_captcha_field
54
+ # invisible_captcha gem automatically adds this, but we can add it manually if needed
55
+ # The field name "spinner" is configured in invisible_captcha initializer
56
+ text_field_tag :spinner, nil, style: "position: absolute; left: -9999px; width: 1px; height: 1px;", tabindex: -1, autocomplete: "off", aria_hidden: true
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module CMS
5
+ module SEOHelper
6
+ #
7
+ # Renders all SEO meta tags for a given page or post
8
+ #
9
+ # @param resource [Panda::CMS::Page, Panda::CMS::Post] The page or post to render meta tags for
10
+ # @return [String] HTML meta tags
11
+ # @visibility public
12
+ #
13
+ def render_seo_meta_tags(resource)
14
+ return "" if resource.blank?
15
+
16
+ tags = []
17
+
18
+ # Basic SEO tags
19
+ tags << tag.meta(name: "description", content: resource.effective_seo_description) if resource.effective_seo_description.present?
20
+ tags << tag.meta(name: "keywords", content: resource.seo_keywords) if resource.seo_keywords.present?
21
+ tags << tag.meta(name: "robots", content: resource.robots_meta_content)
22
+ tags << tag.link(rel: "canonical", href: canonical_url_for(resource))
23
+
24
+ # Open Graph tags
25
+ tags << tag.meta(property: "og:title", content: resource.effective_og_title)
26
+ tags << tag.meta(property: "og:description", content: resource.effective_og_description) if resource.effective_og_description.present?
27
+ tags << tag.meta(property: "og:type", content: resource.og_type)
28
+ tags << tag.meta(property: "og:url", content: canonical_url_for(resource))
29
+
30
+ # Open Graph image
31
+ if resource.og_image.attached?
32
+ og_image_url = url_for(resource.og_image.variant(:og_share))
33
+ tags << tag.meta(property: "og:image", content: og_image_url)
34
+ tags << tag.meta(property: "og:image:width", content: "1200")
35
+ tags << tag.meta(property: "og:image:height", content: "630")
36
+ end
37
+
38
+ # Twitter Card tags (with fallback to OG)
39
+ tags << tag.meta(name: "twitter:card", content: "summary_large_image")
40
+ tags << tag.meta(name: "twitter:title", content: resource.effective_og_title)
41
+ tags << tag.meta(name: "twitter:description", content: resource.effective_og_description) if resource.effective_og_description.present?
42
+
43
+ # Twitter image (same as OG)
44
+ if resource.og_image.attached?
45
+ tags << tag.meta(name: "twitter:image", content: url_for(resource.og_image.variant(:og_share)))
46
+ end
47
+
48
+ safe_join(tags, "\n")
49
+ end
50
+
51
+ #
52
+ # Renders just the page title with SEO optimization
53
+ #
54
+ # @param resource [Panda::CMS::Page, Panda::CMS::Post] The page or post
55
+ # @param separator [String] Separator between page title and site name
56
+ # @param site_name [String] The site name (optional)
57
+ # @return [String] Formatted page title
58
+ # @visibility public
59
+ #
60
+ def seo_title(resource, separator: " · ", site_name: nil)
61
+ parts = [resource.effective_seo_title]
62
+ parts << site_name if site_name.present?
63
+ safe_join(parts, separator)
64
+ end
65
+
66
+ private
67
+
68
+ #
69
+ # Generates the full canonical URL for a resource
70
+ #
71
+ # @param resource [Panda::CMS::Page, Panda::CMS::Post] The page or post
72
+ # @return [String] Full canonical URL
73
+ # @visibility private
74
+ #
75
+ def canonical_url_for(resource)
76
+ # If canonical_url is a full URL, use it as-is
77
+ return resource.canonical_url if resource.canonical_url&.match?(%r{\Ahttps?://})
78
+
79
+ # Otherwise, construct from the path
80
+ path = resource.effective_canonical_url
81
+ "#{request.protocol}#{request.host_with_port}#{path}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -3,5 +3,9 @@ console.debug("[Panda CMS] Controllers loading...");
3
3
  import "./controllers/index.js"
4
4
  console.debug("[Panda CMS] Controllers loaded...");
5
5
 
6
+ // Mark that Panda CMS JavaScript has loaded
7
+ window.pandaCmsLoaded = true
8
+ console.debug("[Panda CMS] Ready!");
9
+
6
10
  // Editor resources are now handled by panda-editor gem
7
11
  // The panda-editor gem will load its own resources when needed
@@ -1,6 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
- import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/cms/editor/editor_js_config";
3
- import { ResourceLoader } from "panda/cms/editor/resource_loader";
2
+ import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/editor/editor_js_config";
3
+ import { ResourceLoader } from "panda/editor/resource_loader";
4
4
 
5
5
  export default class extends Controller {
6
6
  static targets = ["editorContainer", "hiddenField"];
@@ -50,7 +50,7 @@ export default class extends Controller {
50
50
  this.editorContainerTarget.appendChild(holderDiv);
51
51
 
52
52
  const { getEditorConfig } = await import(
53
- "panda/cms/editor/editor_js_config"
53
+ "panda/editor/editor_js_config"
54
54
  );
55
55
 
56
56
  // Get initial content before creating config
@@ -1,14 +1,15 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { PlainTextEditor } from "panda/cms/editor/plain_text_editor"
3
- import { EditorJSInitializer } from "panda/cms/editor/editor_js_initializer"
4
- import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/cms/editor/editor_js_config"
5
- import { ResourceLoader } from "panda/cms/editor/resource_loader"
2
+ import { PlainTextEditor } from "panda/editor/plain_text_editor"
3
+ import { EditorJSInitializer } from "panda/editor/editor_js_initializer"
4
+ import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/editor/editor_js_config"
5
+ import { ResourceLoader } from "panda/editor/resource_loader"
6
6
 
7
7
  export default class extends Controller {
8
8
  static values = {
9
9
  pageId: Number,
10
10
  adminPath: String,
11
- autosave: Boolean
11
+ autosave: Boolean,
12
+ assets: String
12
13
  }
13
14
 
14
15
  connect() {
@@ -65,6 +66,33 @@ export default class extends Controller {
65
66
  this.body = this.frameDocument.body
66
67
  this.head = this.frameDocument.head
67
68
 
69
+ // Inject CMS assets into the iframe head
70
+ if (this.hasAssetsValue && this.assetsValue) {
71
+ console.debug("[Panda CMS] Injecting assets into iframe", {
72
+ assetsLength: this.assetsValue.length,
73
+ assetsPreview: this.assetsValue.substring(0, 200)
74
+ })
75
+ const assetsHTML = this.assetsValue
76
+ const tempDiv = document.createElement('div')
77
+ tempDiv.innerHTML = assetsHTML
78
+
79
+ // Append each node to the iframe's head
80
+ Array.from(tempDiv.childNodes).forEach(node => {
81
+ if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
82
+ const importedNode = this.frameDocument.importNode(node, true)
83
+ this.head.appendChild(importedNode)
84
+ console.debug("[Panda CMS] Injected node:", node.nodeName)
85
+ }
86
+ })
87
+
88
+ console.debug("[Panda CMS] Assets injected successfully - head element count:", this.head.children.length)
89
+ } else {
90
+ console.warn("[Panda CMS] No assets to inject", {
91
+ hasAssetsValue: this.hasAssetsValue,
92
+ assetsValue: this.assetsValue
93
+ })
94
+ }
95
+
68
96
  // Ensure iframe content is properly positioned but doesn't block UI
69
97
  this.body.style.position = "relative"
70
98
  this.body.style.zIndex = "1"
@@ -545,11 +573,10 @@ export default class extends Controller {
545
573
  }
546
574
 
547
575
  setupSlideoverHandling() {
548
- // Watch for slideover toggle
549
- const slideoverToggle = document.getElementById('slideover-toggle')
576
+ // Watch for slideover visibility changes
550
577
  const slideover = document.getElementById('slideover')
551
578
 
552
- if (slideoverToggle && slideover) {
579
+ if (slideover) {
553
580
  const observer = new MutationObserver((mutations) => {
554
581
  mutations.forEach((mutation) => {
555
582
  if (mutation.attributeName === 'class') {