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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5d7ab51a41583d6af2b9c03249894b81eb3301e8a88068bf512fea5ba10891a
4
- data.tar.gz: 9cbbd69b5b6e6ce229fef244135e7abb1e12580be401524b054e94ff227280cc
3
+ metadata.gz: 680bef797106fd65c694455aac3c55bc7799bfdfba990997a96bec44f325f780
4
+ data.tar.gz: a8485d6a4da3f5623531eb14ae6188226204ab8b2bce3381baa3f1c6e3b7caf4
5
5
  SHA512:
6
- metadata.gz: 84f46d5e374a05e046047e7e7751371f4b90d2e86a137df179c02e6e89c08169048e64c18ef38b0b78ab5137c22f55d0777451ab49bb32ab3aa3c67745b6aaed
7
- data.tar.gz: 0b6ad4481db935ed61ca57d087582e8b370fe2ecb6b0e15dadeaa53042a834dbecaf9cd7d0122830788e10b071cce66290d0abd6a0b4649adf23465140854069
6
+ metadata.gz: 3cba49016e04bc8ec5a2f1011897687eeb2478c451c0a9d38feef27605e4ddfe674c9d8543ee92e33b76049d3ecb08bac0bab1e6b790576c5f043683dbca606e
7
+ data.tar.gz: bb6c39903cdf5412c99dae06721a3d908dc0ed6d2e2c6a5c443eb96c6e6e43965029530a2206bce40bf2a8c06ddfd8fafbdf97d341c6f9739269f64693240479
data/README.md CHANGED
@@ -32,16 +32,6 @@ The easiest way for you to get started is to visit http://localhost:3000/admin a
32
32
 
33
33
  When you're ready to configure further, you can set your own configuration in `config/initializers/panda.rb`. Make sure to configure your authentication providers and update the domain restriction!
34
34
 
35
- ## Panda CMS Pro
36
-
37
- Commercial features such as structured **Collections** live in the `panda-cms-pro` gem. Once the pro gem is installed you can:
38
-
39
- - Model repeatable content with collections and items.
40
- - Loop over entries inside layouts using helpers like `panda_cms_collection_items("trustees")`.
41
- - Keep the feature hidden in open-source installs thanks to `Panda::CMS::Features`.
42
-
43
- See `docs/collections.md` for the editor workflow and template examples, and `docs/private-gem-server.md` for hosting the private gem server.
44
-
45
35
  ### Existing applications
46
36
 
47
37
  Add the following to `Gemfile`:
@@ -124,6 +114,7 @@ The CMS automatically loads Core's compiled stylesheet:
124
114
  ```
125
115
 
126
116
  Core's Rack middleware serves this file from the gem, so:
117
+
127
118
  - ✅ No CSS copying or compilation needed
128
119
  - ✅ Styles update automatically when Core updates
129
120
  - ✅ Consistent design across all Panda gems
@@ -136,7 +127,7 @@ For CSS compilation (when contributing to styling), see [Panda Core Asset Compil
136
127
 
137
128
  This is a non-exhuastive list (there will be many more):
138
129
 
139
- * To date, this has only been tested with Rails 7.1, 7.2 and 8.0
130
+ * To date, this has only been tested with Rails 7.1, 7.2 and 8
140
131
  * There may be conflicts if you're not using Tailwind CSS on the frontend. Please report this.
141
132
 
142
133
  ## Contributing
@@ -180,6 +171,83 @@ expect(page.title).to eq("Home")
180
171
 
181
172
  When testing models with file validations or complex callbacks, use the helper methods in `spec/models/panda/cms/page_spec.rb` as a reference.
182
173
 
174
+ ## 🚀 Running CI Locally
175
+
176
+ This project uses a **deterministic CI environment**, based on a single Docker
177
+ image (`panda-cms-test`). This ensures:
178
+
179
+ - identical Ruby/Node/Chrome versions everywhere
180
+ - no drift between local / Docker / GitHub Actions / act
181
+ - fast, stable, reproducible tests
182
+
183
+ There are **three** supported ways to run the full CI suite locally.
184
+
185
+ ---
186
+
187
+ ### 1. Run full CI via Docker Compose
188
+
189
+ ```sh
190
+ bin/ci build # build the local test image
191
+ bin/ci local # run full CI stack locally
192
+ ```
193
+
194
+ This uses `docker-compose.ci.yml` and reproduces the entire GitHub Actions environment.
195
+
196
+ ---
197
+
198
+ ### 2. Run single RSpec execution in the CI container
199
+
200
+ ```sh
201
+ bin/ci test
202
+ ```
203
+
204
+ This mounts your project into the container and executes RSpec exactly as CI does.
205
+
206
+ ---
207
+
208
+ ### 3. Run GitHub Actions locally using act
209
+
210
+ Install act:
211
+
212
+ ```sh
213
+ brew install act
214
+ ```
215
+
216
+ Use the project’s `.actrc`:
217
+
218
+ ```
219
+ -P ubuntu-latest=ghcr.io/tastybamboo/panda-cms-test:local
220
+ --container-options "--shm-size=2gb"
221
+ ```
222
+
223
+ Then run:
224
+
225
+ ```sh
226
+ bin/ci act
227
+ ```
228
+
229
+ This executes **the real GitHub Actions workflow** on your machine.
230
+
231
+ ---
232
+
233
+ ### 4. Continuous Integration on GitHub
234
+
235
+ GitHub Actions uses the same deterministic container image.
236
+ See:
237
+
238
+ ```
239
+ .github/workflows/ci.yml
240
+ ```
241
+
242
+ ---
243
+
244
+ ### 5. Code Coverage
245
+
246
+ Coverage is produced per-suite (models, requests, libs, system) and merged
247
+ into a unified `coverage/` directory via SimpleCov.
248
+
249
+ Artifacts are uploaded automatically on CI.
250
+
183
251
  ## License
184
252
 
185
253
  The gem is available as open source under the terms of the [BSD-3-Clause License](https://opensource.org/licenses/bsd-3-clause).
@@ -0,0 +1 @@
1
+ /* Panda CMS specific styles go here */
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module CMS
5
+ module Admin
6
+ class PopularPagesComponent < Panda::Core::Base
7
+ def initialize(popular_pages:, period_name: "All Time")
8
+ @popular_pages = popular_pages
9
+ @period_name = period_name
10
+ end
11
+
12
+ def view_template
13
+ render Panda::Core::Admin::PanelComponent.new do |panel|
14
+ panel.heading(text: "Popular Pages (#{@period_name})", level: :panel)
15
+
16
+ panel.body do
17
+ if @popular_pages.any?
18
+ div(class: "overflow-y-auto max-h-96") do
19
+ table(class: "min-w-full divide-y divide-gray-300") do
20
+ thead(class: "sticky top-0 bg-white z-10") do
21
+ tr do
22
+ th(class: "py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0") { "Page" }
23
+ th(class: "px-3 py-3.5 text-left text-sm font-semibold text-gray-900") { "Path" }
24
+ th(class: "px-3 py-3.5 text-right text-sm font-semibold text-gray-900") { "Views" }
25
+ end
26
+ end
27
+ tbody(class: "divide-y divide-gray-200") do
28
+ index = 0
29
+ @popular_pages.each do |page_data|
30
+ tr(class: index.even? ? "bg-indigo-50" : "bg-white") do
31
+ td(class: "whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0") do
32
+ a(
33
+ href: view_context.admin_cms_page_path(page_data.id),
34
+ class: "text-indigo-600 hover:text-indigo-900"
35
+ ) { page_data.title }
36
+ end
37
+ td(class: "whitespace-nowrap px-3 py-4 text-sm text-gray-500") do
38
+ a(
39
+ href: page_data.path,
40
+ class: "text-gray-600 hover:text-gray-900",
41
+ target: "_blank"
42
+ ) { page_data.path }
43
+ end
44
+ td(class: "whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-right font-semibold") do
45
+ page_data.visit_count.to_s
46
+ end
47
+ end
48
+ index += 1
49
+ end
50
+ end
51
+ end
52
+ end
53
+ else
54
+ p(class: "text-sm text-gray-500") { "No page visits recorded yet." }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -14,10 +14,12 @@ module Panda
14
14
  prop :editable, _Boolean, default: true
15
15
 
16
16
  def view_template
17
- if @editable_state
18
- render_editable_view
17
+ # Russian doll caching: Cache component output at block_content level
18
+ # Only cache in non-editable mode (public-facing pages)
19
+ if should_cache?
20
+ raw cache_component_output.to_s.html_safe
19
21
  else
20
- raw(@code_content.to_s.html_safe)
22
+ render_content
21
23
  end
22
24
  rescue => e
23
25
  handle_error(e)
@@ -36,9 +38,9 @@ module Panda
36
38
  block = find_block
37
39
  return false if block.nil?
38
40
 
39
- block_content = find_block_content(block)
40
- @code_content = block_content&.content.to_s
41
- @block_content_id = block_content&.id
41
+ @block_content_obj = find_block_content(block)
42
+ @code_content = @block_content_obj&.content.to_s
43
+ @block_content_id = @block_content_obj&.id
42
44
  end
43
45
 
44
46
  def find_block
@@ -82,7 +84,7 @@ module Panda
82
84
  data: {inline_code_editor_target: "codeInput"},
83
85
  class: "w-full h-64 p-3 font-mono text-sm border border-gray-300 rounded focus:ring-primary focus:border-primary",
84
86
  placeholder: "Enter your HTML/embed code here..."
85
- ) { raw(@code_content.to_s) }
87
+ ) { plain @code_content.to_s }
86
88
 
87
89
  div(class: "mt-3 flex justify-end space-x-2") do
88
90
  button(type: "button",
@@ -123,8 +125,11 @@ module Panda
123
125
  end
124
126
 
125
127
  def is_embedded?
126
- # TODO: Check security on this - embed_id should match something?
127
- view_context.request.params[:embed_id].present?
128
+ # Security: Verify embed_id matches the current page being edited
129
+ # This prevents unauthorized editing by ensuring the embed_id in the URL
130
+ # matches the actual page ID from Current.page
131
+ view_context.params[:embed_id].present? &&
132
+ Current.page&.id.to_s == view_context.params[:embed_id].to_s
128
133
  end
129
134
 
130
135
  def handle_error(error)
@@ -141,6 +146,38 @@ module Panda
141
146
  end
142
147
  end
143
148
 
149
+ def render_content
150
+ if @editable_state
151
+ render_editable_view
152
+ else
153
+ raw(@code_content.to_s.html_safe)
154
+ end
155
+ end
156
+
157
+ def should_cache?
158
+ !@editable_state &&
159
+ Panda::CMS.config.performance.dig(:fragment_caching, :enabled) != false &&
160
+ @block_content_obj.present?
161
+ end
162
+
163
+ def cache_component_output
164
+ cache_key = cache_key_for_component
165
+ expires_in = Panda::CMS.config.performance.dig(:fragment_caching, :expires_in) || 1.hour
166
+
167
+ Rails.cache.fetch(cache_key, expires_in: expires_in) do
168
+ render_content_to_string
169
+ end.html_safe
170
+ end
171
+
172
+ def cache_key_for_component
173
+ "panda_cms/code_component/#{@block_content_obj.cache_key_with_version}/#{@key}"
174
+ end
175
+
176
+ def render_content_to_string
177
+ # For code component, we just return the raw HTML content
178
+ @code_content.to_s
179
+ end
180
+
144
181
  class BlockError < StandardError; end
145
182
  end
146
183
  end
@@ -6,7 +6,7 @@ module Panda
6
6
  # @param name [String] The name of the menu to render
7
7
  # @param current_path [String] The current request path for highlighting active items
8
8
  # @param styles [Hash] CSS classes for menu items (default, active, inactive)
9
- # @param overrides [Hash] Menu item overrides (currently unused)
9
+ # @param overrides [Hash] Menu item overrides - supports :hidden_items array to hide specific menu items by text
10
10
  # @param render_page_menu [Boolean] Whether to render sub-page menus
11
11
  # @param page_menu_styles [Hash] Styles for the page menu component
12
12
  class MenuComponent < Panda::Core::Base
@@ -44,11 +44,24 @@ module Panda
44
44
  @menu = Panda::CMS::Menu.find_by(name: @name)
45
45
  return unless @menu
46
46
 
47
- menu_items = @menu.menu_items
48
- menu_items = menu_items.where("depth <= ?", @menu.depth) if @menu.depth
49
- menu_items = menu_items.order(:lft)
47
+ # Fragment caching: Cache menu_items query results
48
+ # Cache key includes menu's updated_at to auto-invalidate on changes
49
+ cache_key = "panda_cms_menu/#{@menu.name}/#{@menu.id}/#{@menu.updated_at.to_i}/items"
50
50
 
51
- @processed_menu_items = menu_items.map do |menu_item|
51
+ menu_items = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
52
+ items = @menu.menu_items
53
+ items = items.where("depth <= ?", @menu.depth) if @menu.depth
54
+ items.order(:lft).to_a # Convert to array for caching
55
+ end
56
+
57
+ # Filter menu items based on overrides
58
+ filtered_menu_items = if @overrides[:hidden_items].present?
59
+ menu_items.reject { |item| @overrides[:hidden_items].include?(item.text) }
60
+ else
61
+ menu_items
62
+ end
63
+
64
+ @processed_menu_items = filtered_menu_items.map do |menu_item|
52
65
  add_css_classes_to_item(menu_item)
53
66
  menu_item
54
67
  end
@@ -36,7 +36,15 @@ module Panda
36
36
  menu = @start_page&.page_menu
37
37
  return if menu.nil?
38
38
 
39
- @menu_item = menu.menu_items.order(:lft)&.first
39
+ # Fragment caching: Cache menu items for this page menu
40
+ # Cache key includes menu's updated_at to auto-invalidate on changes
41
+ cache_key = "panda_cms_page_menu/#{menu.id}/#{menu.updated_at.to_i}/items"
42
+
43
+ cached_items = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
44
+ menu.menu_items.order(:lft).to_a
45
+ end
46
+
47
+ @menu_item = cached_items.first
40
48
 
41
49
  # Set default styles if not already set
42
50
  @styles[:indent_with] ||= "pl-2" if @styles
@@ -18,6 +18,16 @@ module Panda
18
18
  attr_accessor :content, :block_content_id
19
19
 
20
20
  def view_template
21
+ # Russian doll caching: Cache component output at block_content level
22
+ # Only cache in non-editable mode (public-facing pages)
23
+ if should_cache?
24
+ raw cache_component_output
25
+ else
26
+ render_content
27
+ end
28
+ end
29
+
30
+ def render_content
21
31
  div(class: "panda-cms-content", **element_attrs) do
22
32
  if @editable_state
23
33
  # Empty div for EditorJS to initialize into
@@ -199,23 +209,21 @@ module Panda
199
209
  attrs = {class: "panda-cms-content"}
200
210
 
201
211
  if @editable_state
202
- attrs.merge!(
203
- id: "editor-#{@block_content_id}",
204
- data: {
205
- "editable-previous-data": @encoded_data,
206
- "editable-content": @encoded_data,
207
- "editable-initialized": "false",
208
- "editable-version": "2.28.2",
209
- "editable-autosave": "false",
210
- "editable-tools": '{"paragraph":true,"header":true,"list":true,"quote":true,"table":true}',
211
- "editable-kind": "rich_text",
212
- "editable-block-content-id": @block_content_id,
213
- "editable-page-id": Current.page.id,
214
- controller: "editor-js",
215
- "editor-js-initialized-value": "false",
216
- "editor-js-content-value": @encoded_data
217
- }
218
- )
212
+ attrs[:id] = "editor-#{@block_content_id}"
213
+ attrs[:data] = {
214
+ "editable-previous-data": @encoded_data,
215
+ "editable-content": @encoded_data,
216
+ "editable-initialized": "false",
217
+ "editable-version": "2.28.2",
218
+ "editable-autosave": "false",
219
+ "editable-tools": '{"paragraph":true,"header":true,"list":true,"quote":true,"table":true}',
220
+ "editable-kind": "rich_text",
221
+ "editable-block-content-id": @block_content_id,
222
+ "editable-page-id": Current.page.id,
223
+ controller: "editor-js",
224
+ "editor-js-initialized-value": "false",
225
+ "editor-js-content-value": @encoded_data
226
+ }
219
227
  end
220
228
 
221
229
  attrs
@@ -247,6 +255,30 @@ module Panda
247
255
 
248
256
  nil
249
257
  end
258
+
259
+ def should_cache?
260
+ !@editable_state &&
261
+ Panda::CMS.config.performance.dig(:fragment_caching, :enabled) != false &&
262
+ @block_content.present?
263
+ end
264
+
265
+ def cache_component_output
266
+ cache_key = cache_key_for_component
267
+ expires_in = Panda::CMS.config.performance.dig(:fragment_caching, :expires_in) || 1.hour
268
+
269
+ Rails.cache.fetch(cache_key, expires_in: expires_in) do
270
+ render_content_to_string
271
+ end.html_safe
272
+ end
273
+
274
+ def cache_key_for_component
275
+ "panda_cms/rich_text_component/#{@block_content.cache_key_with_version}/#{@key}"
276
+ end
277
+
278
+ def render_content_to_string
279
+ # Render the component HTML to a string for caching
280
+ view_context.content_tag(:div, @rendered_content.html_safe, class: "panda-cms-content", **element_attrs)
281
+ end
250
282
  end
251
283
  end
252
284
  end
@@ -18,7 +18,13 @@ module Panda
18
18
  def view_template
19
19
  return unless @content
20
20
 
21
- span(**element_attrs) { raw(@content.html_safe) }
21
+ # Russian doll caching: Cache component output at block_content level
22
+ # Only cache in non-editable mode (public-facing pages)
23
+ if should_cache?
24
+ raw cache_component_output
25
+ else
26
+ render_content
27
+ end
22
28
  rescue => e
23
29
  handle_error(e)
24
30
  end
@@ -35,11 +41,11 @@ module Panda
35
41
  block = find_block
36
42
  return false if block.nil?
37
43
 
38
- block_content = find_block_content(block)
39
- @plain_text = block_content&.content.to_s
44
+ find_block_content(block)
45
+ @plain_text = @block_content_obj&.content.to_s
40
46
 
41
47
  if @editable_state
42
- setup_editable_content(block_content)
48
+ setup_editable_content(@block_content_obj)
43
49
  else
44
50
  @content = prepare_content_for_display(@plain_text)
45
51
  end
@@ -54,7 +60,7 @@ module Panda
54
60
  end
55
61
 
56
62
  def find_block_content(block)
57
- block.block_contents.find_by(panda_cms_page_id: Current.page.id)
63
+ @block_content_obj = block.block_contents.find_by(panda_cms_page_id: Current.page.id)
58
64
  end
59
65
 
60
66
  def setup_editable_content(block_content)
@@ -66,14 +72,12 @@ module Panda
66
72
  attrs = @attrs.merge(id: element_id)
67
73
 
68
74
  if @editable_state
69
- attrs.merge!(
70
- contenteditable: "plaintext-only",
71
- data: {
72
- "editable-kind": "plain_text",
73
- "editable-page-id": Current.page.id,
74
- "editable-block-content-id": @block_content_id
75
- }
76
- )
75
+ attrs[:contenteditable] = "plaintext-only"
76
+ attrs[:data] = {
77
+ "editable-kind": "plain_text",
78
+ "editable-page-id": Current.page.id,
79
+ "editable-block-content-id": @block_content_id
80
+ }
77
81
  end
78
82
 
79
83
  attrs
@@ -92,13 +96,41 @@ module Panda
92
96
  view_context.params[:embed_id].present? && view_context.params[:embed_id] == Current.page.id
93
97
  end
94
98
 
95
- def handle_error(error)
99
+ def handle_error(_error)
96
100
  if !Rails.env.production? || defined?(Sentry)
97
101
  raise Panda::CMS::MissingBlockError, "Block with key #{@key} not found for page #{Current.page.title}"
98
102
  end
99
103
 
100
104
  false
101
105
  end
106
+
107
+ def should_cache?
108
+ !@editable_state &&
109
+ Panda::CMS.config.performance.dig(:fragment_caching, :enabled) != false &&
110
+ @block_content_obj.present?
111
+ end
112
+
113
+ def cache_component_output
114
+ cache_key = cache_key_for_component
115
+ expires_in = Panda::CMS.config.performance.dig(:fragment_caching, :expires_in) || 1.hour
116
+
117
+ Rails.cache.fetch(cache_key, expires_in: expires_in) do
118
+ render_content_to_string
119
+ end.html_safe
120
+ end
121
+
122
+ def cache_key_for_component
123
+ "panda_cms/text_component/#{@block_content_obj.cache_key_with_version}/#{@key}"
124
+ end
125
+
126
+ def render_content
127
+ span(**element_attrs) { raw(@content.html_safe) }
128
+ end
129
+
130
+ def render_content_to_string
131
+ # Phlex doesn't have a direct way to capture output, so we render directly
132
+ view_context.content_tag(:span, @content.html_safe, **element_attrs)
133
+ end
102
134
  end
103
135
  end
104
136
  end
@@ -36,7 +36,7 @@ module Panda
36
36
  # @type GET
37
37
  def edit
38
38
  add_breadcrumb @menu.name, edit_admin_cms_menu_path(@menu)
39
- render :edit, locals: {menu: @menu}
39
+ render :edit
40
40
  end
41
41
 
42
42
  # @type PATCH/PUT
@@ -44,7 +44,7 @@ module Panda
44
44
  if @menu.update(menu_params)
45
45
  redirect_to admin_cms_menus_path, notice: "Menu was successfully updated."
46
46
  else
47
- render :edit, locals: {menu: @menu}, status: :unprocessable_entity
47
+ render :edit, status: :unprocessable_entity
48
48
  end
49
49
  end
50
50
 
@@ -66,7 +66,7 @@ module Panda
66
66
  flash: {success: "This page was successfully updated!"}
67
67
  else
68
68
  flash[:error] = "There was an error updating the page."
69
- render :edit, status: :unprocessable_entity
69
+ render :edit, locals: {page: page, template: page.template}, status: :unprocessable_entity
70
70
  end
71
71
  end
72
72
 
@@ -99,7 +99,11 @@ module Panda
99
99
  # @type private
100
100
  # @return ActionController::StrongParameters
101
101
  def page_params
102
- params.require(:page).permit(:title, :path, :panda_cms_template_id, :parent_id, :status, :page_type)
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
+ )
103
107
  end
104
108
  end
105
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