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.
- checksums.yaml +4 -4
- data/README.md +79 -11
- data/app/assets/tailwind/panda/cms/_application.css +1 -0
- data/app/components/panda/cms/admin/popular_pages_component.rb +62 -0
- data/app/components/panda/cms/code_component.rb +46 -9
- data/app/components/panda/cms/menu_component.rb +18 -5
- data/app/components/panda/cms/page_menu_component.rb +9 -1
- data/app/components/panda/cms/rich_text_component.rb +49 -17
- data/app/components/panda/cms/text_component.rb +46 -14
- data/app/controllers/panda/cms/admin/menus_controller.rb +2 -2
- data/app/controllers/panda/cms/admin/pages_controller.rb +6 -2
- data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
- data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
- data/app/controllers/panda/cms/pages_controller.rb +7 -2
- data/app/controllers/panda/cms/posts_controller.rb +16 -0
- data/app/helpers/panda/cms/application_helper.rb +2 -3
- data/app/helpers/panda/cms/asset_helper.rb +14 -72
- data/app/helpers/panda/cms/forms_helper.rb +60 -0
- data/app/helpers/panda/cms/seo_helper.rb +85 -0
- data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +4 -0
- data/app/javascript/panda/cms/controllers/editor_form_controller.js +3 -3
- data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +35 -8
- data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
- data/app/javascript/panda/cms/controllers/index.js +6 -0
- data/app/javascript/panda/cms/controllers/menu_form_controller.js +14 -1
- data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
- data/app/javascript/panda/cms/stimulus-loading.js +2 -1
- data/app/jobs/panda/cms/record_visit_job.rb +2 -1
- data/app/models/panda/cms/menu.rb +12 -0
- data/app/models/panda/cms/page.rb +106 -0
- data/app/models/panda/cms/post.rb +97 -0
- data/app/models/panda/cms/visit.rb +16 -1
- data/app/services/panda/social/instagram_feed_service.rb +54 -54
- data/app/views/layouts/homepage.html.erb +1 -4
- data/app/views/layouts/page.html.erb +1 -4
- data/app/views/panda/cms/admin/dashboard/show.html.erb +11 -4
- data/app/views/panda/cms/admin/files/index.html.erb +1 -1
- data/app/views/panda/cms/admin/forms/new.html.erb +1 -1
- data/app/views/panda/cms/admin/forms/show.html.erb +3 -3
- data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +3 -3
- data/app/views/panda/cms/admin/menus/edit.html.erb +12 -14
- data/app/views/panda/cms/admin/menus/index.html.erb +1 -1
- data/app/views/panda/cms/admin/menus/new.html.erb +6 -8
- data/app/views/panda/cms/admin/pages/edit.html.erb +213 -20
- data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
- data/app/views/panda/cms/admin/posts/_form.html.erb +47 -8
- data/app/views/panda/cms/admin/posts/edit.html.erb +1 -1
- data/app/views/panda/cms/admin/posts/index.html.erb +4 -4
- data/app/views/panda/cms/shared/_favicons.html.erb +7 -7
- data/app/views/shared/_header.html.erb +1 -4
- data/config/brakeman.ignore +38 -0
- data/config/importmap.rb +7 -6
- data/config/initializers/groupdate.rb +5 -0
- data/config/locales/en.yml +42 -2
- data/config/routes.rb +1 -1
- data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +0 -10
- data/db/migrate/20240316230706_add_nested_to_panda_cms_menu_items.rb +0 -6
- data/db/migrate/20240317230622_create_panda_cms_visits.rb +1 -1
- data/db/migrate/20240805121123_create_panda_cms_posts.rb +1 -1
- data/db/migrate/20240806112735_fix_panda_cms_visits_column_names.rb +1 -1
- data/db/migrate/20240923234535_add_depth_to_panda_cms_menus.rb +0 -6
- data/db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb +1 -3
- data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
- data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
- data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
- data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
- data/db/migrate/20251117234530_add_index_to_visited_at_on_panda_cms_visits.rb +7 -0
- data/db/migrate/20251118015100_backfill_visited_at_for_existing_visits.rb +17 -0
- data/db/seeds.rb +5 -0
- data/lib/panda/cms/asset_loader.rb +42 -78
- data/lib/panda/cms/bulk_editor.rb +288 -12
- data/lib/panda/cms/engine/asset_config.rb +49 -0
- data/lib/panda/cms/engine/autoload_config.rb +37 -0
- data/lib/panda/cms/engine/backtrace_config.rb +42 -0
- data/lib/panda/cms/engine/core_config.rb +106 -0
- data/lib/panda/cms/engine/helper_config.rb +20 -0
- data/lib/panda/cms/engine/route_config.rb +33 -0
- data/lib/panda/cms/engine/view_component_config.rb +31 -0
- data/lib/panda/cms/engine.rb +32 -228
- data/lib/{panda-cms → panda/cms}/version.rb +1 -1
- data/lib/panda/cms.rb +12 -0
- data/lib/panda-cms.rb +24 -3
- data/lib/tasks/ci.rake +0 -0
- metadata +32 -67
- data/app/assets/builds/panda.cms.css +0 -2754
- data/app/assets/stylesheets/panda/cms/application.tailwind.css +0 -162
- data/app/assets/stylesheets/panda/cms/editor.css +0 -120
- data/app/assets/tailwind/application.css +0 -178
- data/app/assets/tailwind/tailwind.config.js +0 -15
- data/app/javascript/panda_cms/stimulus-loading.js +0 -39
- data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
- data/config/initializers/inflections.rb +0 -5
- data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +0 -31
- data/db/migrate/20240317010532_create_panda_cms_users.rb +0 -14
- data/db/migrate/20240324205703_create_active_storage_tables.active_storage.rb +0 -61
- data/db/migrate/20240408084718_default_panda_cms_users_admin_to_false.rb +0 -7
- data/db/migrate/20240701225422_add_service_name_to_active_storage_blobs.active_storage.rb +0 -24
- data/db/migrate/20240701225423_create_active_storage_variant_records.active_storage.rb +0 -30
- data/db/migrate/20240701225424_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +0 -10
- data/db/migrate/20241119214548_convert_post_content_to_editor_js.rb +0 -37
- data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +0 -113
- data/lib/generators/panda/cms/install_generator.rb +0 -28
- data/lib/tasks/assets.rake +0 -540
- data/public/panda-cms-assets/editor-js/core/editorjs.min.js +0 -83
- data/public/panda-cms-assets/editor-js/plugins/embed.min.js +0 -2
- data/public/panda-cms-assets/editor-js/plugins/header.min.js +0 -9
- data/public/panda-cms-assets/editor-js/plugins/nested-list.min.js +0 -2
- data/public/panda-cms-assets/editor-js/plugins/paragraph.min.js +0 -9
- data/public/panda-cms-assets/editor-js/plugins/quote.min.js +0 -2
- data/public/panda-cms-assets/editor-js/plugins/simple-image.min.js +0 -2
- data/public/panda-cms-assets/editor-js/plugins/table.min.js +0 -2
- data/public/panda-cms-assets/favicons/android-chrome-192x192.png +0 -0
- data/public/panda-cms-assets/favicons/android-chrome-512x512.png +0 -0
- data/public/panda-cms-assets/favicons/apple-touch-icon.png +0 -0
- data/public/panda-cms-assets/favicons/browserconfig.xml +0 -9
- data/public/panda-cms-assets/favicons/favicon-16x16.png +0 -0
- data/public/panda-cms-assets/favicons/favicon-32x32.png +0 -0
- data/public/panda-cms-assets/favicons/favicon.ico +0 -0
- data/public/panda-cms-assets/favicons/mstile-150x150.png +0 -0
- data/public/panda-cms-assets/favicons/safari-pinned-tab.svg +0 -61
- data/public/panda-cms-assets/favicons/site.webmanifest +0 -14
- data/public/panda-cms-assets/manifest.json +0 -20
- data/public/panda-cms-assets/panda-cms-0.7.4.css +0 -26
- data/public/panda-cms-assets/panda-cms-0.7.4.js +0 -150
- data/public/panda-cms-assets/panda-logo-screenprint.png +0 -0
- data/public/panda-cms-assets/panda-nav.png +0 -0
- data/public/panda-cms-assets/rich_text_editor.css +0 -568
- /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
|
-
|
|
6
|
+
# Spam protection - invisible honeypot field
|
|
7
|
+
invisible_captcha only: [:create], on_spam: :log_spam
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
|
|
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
|
-
#
|
|
21
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
"<!-- Compiled assets available: #{compiled_available} -->",
|
|
83
|
+
"<!-- Using importmaps: true (no compilation) -->",
|
|
144
84
|
"<!-- JavaScript URL: #{js_url} -->",
|
|
145
|
-
"<!-- CSS URL: #{css_url || "
|
|
85
|
+
"<!-- CSS URL: #{css_url || "CSS from panda-core"} -->",
|
|
146
86
|
"<!-- Rails environment: #{Rails.env} -->",
|
|
147
|
-
"<!--
|
|
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/
|
|
3
|
-
import { ResourceLoader } from "panda/
|
|
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/
|
|
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/
|
|
3
|
-
import { EditorJSInitializer } from "panda/
|
|
4
|
-
import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/
|
|
5
|
-
import { ResourceLoader } from "panda/
|
|
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
|
|
549
|
-
const slideoverToggle = document.getElementById('slideover-toggle')
|
|
576
|
+
// Watch for slideover visibility changes
|
|
550
577
|
const slideover = document.getElementById('slideover')
|
|
551
578
|
|
|
552
|
-
if (
|
|
579
|
+
if (slideover) {
|
|
553
580
|
const observer = new MutationObserver((mutations) => {
|
|
554
581
|
mutations.forEach((mutation) => {
|
|
555
582
|
if (mutation.attributeName === 'class') {
|