panda-cms 0.8.2 → 0.10.2

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +75 -5
  3. data/app/components/panda/cms/code_component.rb +154 -39
  4. data/app/components/panda/cms/grid_component.rb +26 -6
  5. data/app/components/panda/cms/menu_component.rb +72 -34
  6. data/app/components/panda/cms/page_menu_component.rb +102 -13
  7. data/app/components/panda/cms/rich_text_component.rb +229 -139
  8. data/app/components/panda/cms/text_component.rb +107 -42
  9. data/app/controllers/panda/cms/admin/base_controller.rb +19 -3
  10. data/app/controllers/panda/cms/admin/dashboard_controller.rb +3 -3
  11. data/app/controllers/panda/cms/admin/files_controller.rb +7 -0
  12. data/app/controllers/panda/cms/admin/menus_controller.rb +47 -3
  13. data/app/controllers/panda/cms/admin/pages_controller.rb +11 -2
  14. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  15. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  16. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  17. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  18. data/app/helpers/panda/cms/application_helper.rb +17 -4
  19. data/app/helpers/panda/cms/asset_helper.rb +14 -61
  20. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  21. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  22. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +5 -1
  23. data/app/javascript/panda/cms/controllers/code_editor_controller.js +95 -0
  24. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
  25. data/app/javascript/panda/cms/controllers/file_gallery_controller.js +128 -0
  26. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  27. data/app/javascript/panda/cms/controllers/index.js +54 -13
  28. data/app/javascript/panda/cms/controllers/inline_code_editor_controller.js +96 -0
  29. data/app/javascript/panda/cms/controllers/menu_form_controller.js +53 -0
  30. data/app/javascript/panda/cms/controllers/nested_form_controller.js +35 -0
  31. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  32. data/app/javascript/panda/cms/controllers/tree_controller.js +214 -0
  33. data/app/javascript/panda/cms/stimulus-loading.js +6 -7
  34. data/app/models/panda/cms/block_content.rb +9 -0
  35. data/app/models/panda/cms/menu.rb +12 -0
  36. data/app/models/panda/cms/page.rb +147 -0
  37. data/app/models/panda/cms/post.rb +98 -0
  38. data/app/views/layouts/homepage.html.erb +1 -4
  39. data/app/views/layouts/page.html.erb +1 -4
  40. data/app/views/panda/cms/admin/dashboard/show.html.erb +5 -5
  41. data/app/views/panda/cms/admin/files/_file_details.html.erb +45 -0
  42. data/app/views/panda/cms/admin/files/index.html.erb +11 -118
  43. data/app/views/panda/cms/admin/forms/index.html.erb +2 -2
  44. data/app/views/panda/cms/admin/forms/new.html.erb +1 -2
  45. data/app/views/panda/cms/admin/forms/show.html.erb +15 -30
  46. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +11 -0
  47. data/app/views/panda/cms/admin/menus/edit.html.erb +62 -0
  48. data/app/views/panda/cms/admin/menus/index.html.erb +3 -2
  49. data/app/views/panda/cms/admin/menus/new.html.erb +38 -0
  50. data/app/views/panda/cms/admin/pages/edit.html.erb +147 -22
  51. data/app/views/panda/cms/admin/pages/index.html.erb +49 -11
  52. data/app/views/panda/cms/admin/pages/new.html.erb +3 -11
  53. data/app/views/panda/cms/admin/posts/_form.html.erb +44 -15
  54. data/app/views/panda/cms/admin/posts/edit.html.erb +2 -2
  55. data/app/views/panda/cms/admin/posts/index.html.erb +6 -6
  56. data/app/views/panda/cms/admin/posts/new.html.erb +1 -1
  57. data/app/views/panda/cms/admin/settings/bulk_editor/new.html.erb +1 -1
  58. data/app/views/panda/cms/admin/settings/index.html.erb +3 -3
  59. data/app/views/shared/_header.html.erb +1 -4
  60. data/config/brakeman.ignore +38 -0
  61. data/config/importmap.rb +10 -10
  62. data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +31 -0
  63. data/config/initializers/panda/cms.rb +52 -10
  64. data/config/locales/en.yml +41 -0
  65. data/config/routes.rb +5 -3
  66. data/db/migrate/20240305000000_convert_html_content_to_editor_js.rb +2 -2
  67. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +6 -1
  68. data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +23 -21
  69. data/db/migrate/20251104150640_add_cached_last_updated_at_to_panda_cms_pages.rb +22 -0
  70. data/db/migrate/20251104172242_add_page_type_to_panda_cms_pages.rb +6 -0
  71. data/db/migrate/20251104172638_set_page_types_for_existing_pages.rb +27 -0
  72. data/db/migrate/20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb +21 -0
  73. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  74. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  75. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  76. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  77. data/lib/generators/panda/cms/install_generator.rb +2 -5
  78. data/lib/panda/cms/asset_loader.rb +46 -76
  79. data/lib/panda/cms/bulk_editor.rb +288 -12
  80. data/lib/panda/cms/debug.rb +29 -0
  81. data/lib/panda/cms/engine/asset_config.rb +49 -0
  82. data/lib/panda/cms/engine/autoload_config.rb +19 -0
  83. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  84. data/lib/panda/cms/engine/core_config.rb +106 -0
  85. data/lib/panda/cms/engine/helper_config.rb +20 -0
  86. data/lib/panda/cms/engine/route_config.rb +34 -0
  87. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  88. data/lib/panda/cms/engine.rb +44 -162
  89. data/lib/panda/cms/features.rb +52 -0
  90. data/lib/panda/cms.rb +10 -0
  91. data/lib/panda-cms/version.rb +1 -1
  92. data/lib/panda-cms.rb +20 -7
  93. data/lib/tasks/panda_cms_tasks.rake +16 -0
  94. metadata +41 -50
  95. data/app/components/panda/cms/admin/container_component.html.erb +0 -13
  96. data/app/components/panda/cms/admin/flash_message_component.html.erb +0 -31
  97. data/app/components/panda/cms/admin/panel_component.html.erb +0 -7
  98. data/app/components/panda/cms/admin/slideover_component.html.erb +0 -9
  99. data/app/components/panda/cms/admin/slideover_component.rb +0 -15
  100. data/app/components/panda/cms/admin/statistics_component.html.erb +0 -4
  101. data/app/components/panda/cms/admin/statistics_component.rb +0 -16
  102. data/app/components/panda/cms/admin/tab_bar_component.html.erb +0 -35
  103. data/app/components/panda/cms/admin/tab_bar_component.rb +0 -15
  104. data/app/components/panda/cms/admin/table_component.html.erb +0 -29
  105. data/app/components/panda/cms/admin/user_activity_component.html.erb +0 -7
  106. data/app/components/panda/cms/admin/user_activity_component.rb +0 -20
  107. data/app/components/panda/cms/admin/user_display_component.html.erb +0 -17
  108. data/app/components/panda/cms/admin/user_display_component.rb +0 -21
  109. data/app/components/panda/cms/grid_component.html.erb +0 -6
  110. data/app/components/panda/cms/menu_component.html.erb +0 -6
  111. data/app/components/panda/cms/page_menu_component.html.erb +0 -21
  112. data/app/components/panda/cms/rich_text_component.html.erb +0 -90
  113. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  114. data/app/views/layouts/panda/cms/application.html.erb +0 -42
  115. data/app/views/panda/cms/admin/shared/_breadcrumbs.html.erb +0 -28
  116. data/app/views/panda/cms/admin/shared/_flash.html.erb +0 -5
  117. data/app/views/panda/cms/admin/shared/_sidebar.html.erb +0 -41
  118. data/app/views/panda/cms/shared/_footer.html.erb +0 -2
  119. data/app/views/panda/cms/shared/_header.html.erb +0 -25
  120. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  121. data/config/initializers/inflections.rb +0 -5
  122. data/config/initializers/panda/cms/healthcheck_log_silencer.rb +0 -13
  123. data/lib/tasks/assets.rake +0 -587
@@ -4,7 +4,8 @@ module Panda
4
4
  module CMS
5
5
  module Admin
6
6
  class MenusController < ::Panda::CMS::Admin::BaseController
7
- before_action :set_initial_breadcrumb, only: %i[index]
7
+ before_action :set_initial_breadcrumb, only: %i[index new edit]
8
+ before_action :set_menu, only: %i[edit update destroy]
8
9
 
9
10
  # Lists all menus which can be managed by the administrator
10
11
  # @type GET
@@ -14,10 +15,53 @@ module Panda
14
15
  render :index, locals: {menus: menus}
15
16
  end
16
17
 
18
+ # @type GET
19
+ def new
20
+ menu = Panda::CMS::Menu.new
21
+ add_breadcrumb "New Menu", new_admin_cms_menu_path
22
+ render :new, locals: {menu: menu}
23
+ end
24
+
25
+ # @type POST
26
+ def create
27
+ menu = Panda::CMS::Menu.new(menu_params)
28
+
29
+ if menu.save
30
+ redirect_to admin_cms_menus_path, notice: "Menu was successfully created."
31
+ else
32
+ render :new, locals: {menu: menu}, status: :unprocessable_entity
33
+ end
34
+ end
35
+
36
+ # @type GET
37
+ def edit
38
+ add_breadcrumb @menu.name, edit_admin_cms_menu_path(@menu)
39
+ render :edit
40
+ end
41
+
42
+ # @type PATCH/PUT
43
+ def update
44
+ if @menu.update(menu_params)
45
+ redirect_to admin_cms_menus_path, notice: "Menu was successfully updated."
46
+ else
47
+ render :edit, status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ # @type DELETE
52
+ def destroy
53
+ @menu.destroy
54
+ redirect_to admin_cms_menus_path, notice: "Menu was successfully deleted."
55
+ end
56
+
17
57
  private
18
58
 
19
- def menu
20
- @menu ||= Panda::CMS::Menu.find(params[:id])
59
+ def set_menu
60
+ @menu = Panda::CMS::Menu.find(params[:id])
61
+ end
62
+
63
+ def menu_params
64
+ params.require(:menu).permit(:name, :kind, :start_page_id, menu_items_attributes: [:id, :text, :external_url, :panda_cms_page_id, :_destroy])
21
65
  end
22
66
 
23
67
  def set_initial_breadcrumb
@@ -25,6 +25,11 @@ module Panda
25
25
  # Loads the page editor
26
26
  # @type GET
27
27
  def edit
28
+ # Add all ancestor pages to breadcrumbs (excluding homepage at depth 0)
29
+ page.ancestors.select { |anc| anc.depth > 0 }.each do |ancestor|
30
+ add_breadcrumb ancestor.title, edit_admin_cms_page_path(ancestor)
31
+ end
32
+
28
33
  add_breadcrumb page.title, edit_admin_cms_page_path(page)
29
34
 
30
35
  render :edit, locals: {page: page, template: page.template}
@@ -61,7 +66,7 @@ module Panda
61
66
  flash: {success: "This page was successfully updated!"}
62
67
  else
63
68
  flash[:error] = "There was an error updating the page."
64
- render :edit, status: :unprocessable_entity
69
+ render :edit, locals: {page: page, template: page.template}, status: :unprocessable_entity
65
70
  end
66
71
  end
67
72
 
@@ -94,7 +99,11 @@ module Panda
94
99
  # @type private
95
100
  # @return ActionController::StrongParameters
96
101
  def page_params
97
- params.require(:page).permit(:title, :path, :panda_cms_template_id, :parent_id, :status)
102
+ params.require(:page).permit(
103
+ :title, :path, :panda_cms_template_id, :parent_id, :status, :page_type,
104
+ :seo_title, :seo_description, :seo_keywords, :seo_index_mode, :canonical_url,
105
+ :og_title, :og_description, :og_type, :og_image, :inherit_seo
106
+ )
98
107
  end
99
108
  end
100
109
  end
@@ -119,7 +119,9 @@ module Panda
119
119
  :status,
120
120
  :published_at,
121
121
  :author_id,
122
- :content
122
+ :content,
123
+ :seo_title, :seo_description, :seo_keywords, :seo_index_mode, :canonical_url,
124
+ :og_title, :og_description, :og_type, :og_image
123
125
  )
124
126
  end
125
127
 
@@ -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.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.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, *, **, &)
@@ -33,7 +32,7 @@ module Panda
33
32
  if match == :starts_with
34
33
  return request.path.starts_with?(path)
35
34
  elsif match == :exact
36
- return (request.path == path)
35
+ return request.path == path
37
36
  end
38
37
 
39
38
  false
@@ -44,9 +43,23 @@ module Panda
44
43
  link_to(name, options, html_options, &)
45
44
  end
46
45
 
46
+ def panda_cms_collection(slug, include_unpublished: false)
47
+ Panda::CMS::Features.require!(:collections)
48
+ Panda::CMS::Collections.fetch(slug, include_unpublished: include_unpublished)
49
+ end
50
+
51
+ def panda_cms_collection_items(slug, include_unpublished: false)
52
+ Panda::CMS::Features.require!(:collections)
53
+ Panda::CMS::Collections.items(slug, include_unpublished: include_unpublished)
54
+ end
55
+
56
+ def panda_cms_feature_enabled?(name)
57
+ Panda::CMS::Features.enabled?(name)
58
+ end
59
+
47
60
  def panda_cms_form_with(**options, &)
48
61
  options[:builder] = Panda::Core::FormBuilder
49
- 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(" ")
50
63
  form_with(**options, &)
51
64
  end
52
65
 
@@ -24,40 +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
- # Importmap asset - use type: "module"
57
- defer_option = (ENV["GITHUB_ACTIONS"] == "true") ? {} : {defer: true}
58
- javascript_include_tag(js_url, type: "module", **defer_option)
59
- end
60
- end
27
+ #
28
+ # Delegates to Core's helper which automatically includes all registered modules.
29
+ alias_method :panda_cms_javascript, :panda_core_javascript
61
30
 
62
31
  # Include only Panda CMS CSS
63
32
  def panda_cms_stylesheet
@@ -106,39 +75,16 @@ module Panda
106
75
  version = Panda::CMS::VERSION
107
76
  js_url = Panda::CMS::AssetLoader.javascript_url
108
77
  css_url = Panda::CMS::AssetLoader.css_url
109
- using_github = Panda::CMS::AssetLoader.use_github_assets?
110
- compiled_available = Panda::CMS::AssetLoader.send(:compiled_assets_available?)
111
-
112
- # Additional CI debugging
113
- asset_file_exists = js_url && File.exist?(Rails.root.join("public#{js_url}"))
114
- ci_env = ENV["GITHUB_ACTIONS"] == "true"
115
-
116
- # Check what script tag will be generated
117
- script_tag_preview = if using_github
118
- tag_options = {src: js_url}
119
- tag_options[:defer] = true unless ci_env
120
- if !js_url.include?("panda-cms-assets")
121
- tag_options[:type] = "module"
122
- end
123
- "Script tag: <script#{tag_options.map { |k, v| (v == true) ? " #{k}" : " #{k}=\"#{v}\"" }.join}></script>"
124
- else
125
- "Using development assets"
126
- end
78
+ Panda::CMS::AssetLoader.use_github_assets?
127
79
 
128
80
  debug_info = [
129
81
  "<!-- Panda CMS Asset Debug Info -->",
130
82
  "<!-- Version: #{version} -->",
131
- "<!-- Using GitHub assets: #{using_github} -->",
132
- "<!-- Compiled assets available: #{compiled_available} -->",
83
+ "<!-- Using importmaps: true (no compilation) -->",
133
84
  "<!-- JavaScript URL: #{js_url} -->",
134
- "<!-- CSS URL: #{css_url || "none"} -->",
85
+ "<!-- CSS URL: #{css_url || "CSS from panda-core"} -->",
135
86
  "<!-- Rails environment: #{Rails.env} -->",
136
- "<!-- Asset file exists: #{asset_file_exists} -->",
137
- "<!-- Rails root: #{Rails.root} -->",
138
- "<!-- CI environment: #{ci_env} -->",
139
- "<!-- #{script_tag_preview} -->",
140
- "<!-- Params embed_id: #{params[:embed_id] if respond_to?(:params)} -->",
141
- "<!-- Compiled at: #{Time.now.utc.iso8601} -->"
87
+ "<!-- Rails root: #{Rails.root} -->"
142
88
  ]
143
89
 
144
90
  debug_info.join("\n").html_safe
@@ -183,6 +129,13 @@ module Panda
183
129
  ].join("\n").html_safe
184
130
  end
185
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
+
186
139
  private
187
140
 
188
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
@@ -1,7 +1,11 @@
1
1
  import "@hotwired/turbo"
2
2
  console.debug("[Panda CMS] Controllers loading...");
3
- import "controllers"
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