plum-cms 0.2.3 → 0.3.0

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -1
  3. data/README.md +5 -1
  4. data/app/assets/builds/tailwind.css +1 -1
  5. data/app/assets/stylesheets/plum/comments.css +1 -0
  6. data/app/controllers/plum/cp/comments_controller.rb +55 -0
  7. data/app/controllers/plum/cp/content_types_controller.rb +1 -1
  8. data/app/controllers/plum/cp/entries_controller.rb +1 -1
  9. data/app/controllers/plum/discussions_controller.rb +46 -0
  10. data/app/helpers/plum/comments_helper.rb +20 -0
  11. data/app/models/plum/comment.rb +40 -0
  12. data/app/models/plum/comment_moderation.rb +7 -0
  13. data/app/models/plum/comment_report.rb +7 -0
  14. data/app/models/plum/commentable.rb +25 -0
  15. data/app/models/plum/discussion.rb +17 -0
  16. data/app/models/plum/entry.rb +11 -0
  17. data/app/models/plum/site.rb +1 -0
  18. data/app/services/plum/comments/context.rb +70 -0
  19. data/app/services/plum/comments/rate_limited.rb +5 -0
  20. data/app/services/plum/comments/theme_renderer.rb +66 -0
  21. data/app/services/plum/config_sync.rb +3 -0
  22. data/app/services/plum/liquid_context.rb +8 -0
  23. data/app/services/plum/liquid_renderer.rb +8 -0
  24. data/app/services/plum/site_archive.rb +4 -4
  25. data/app/themes/default/layouts/base.liquid +1 -1
  26. data/app/themes/default/templates/comments/_comment.liquid +8 -0
  27. data/app/themes/default/templates/comments/_form.liquid +7 -0
  28. data/app/themes/default/templates/comments/_report.liquid +6 -0
  29. data/app/themes/default/templates/comments/show.liquid +15 -0
  30. data/app/themes/default/templates/entries/_default.liquid +1 -0
  31. data/app/themes/default/templates/entries/landing.liquid +1 -0
  32. data/app/themes/default/templates/entries/pages.liquid +1 -0
  33. data/app/themes/default/templates/entries/posts.liquid +1 -0
  34. data/app/views/layouts/plum/cp.html.erb +3 -0
  35. data/app/views/layouts/plum/discussion.html.erb +5 -0
  36. data/app/views/plum/cp/comments/index.html.erb +16 -0
  37. data/app/views/plum/cp/content_types/_form.html.erb +3 -0
  38. data/app/views/plum/cp/entries/_form.html.erb +3 -0
  39. data/app/views/plum/discussions/_comments.html.erb +13 -0
  40. data/app/views/plum/discussions/_form.html.erb +6 -0
  41. data/app/views/plum/discussions/show.html.erb +19 -0
  42. data/config/plum_routes.rb +6 -0
  43. data/db/engine_migrate/20261005190000_create_plum_comments.rb +48 -0
  44. data/docs/comments.md +140 -0
  45. data/docs/releases/0.3.0-validation.md +15 -0
  46. data/lib/plum/comments/configuration.rb +43 -0
  47. data/lib/plum/configuration.rb +3 -1
  48. data/lib/plum/version.rb +1 -1
  49. metadata +27 -2
@@ -109,6 +109,7 @@ module Plum
109
109
  record.name = config["name"]
110
110
  record.icon = config["icon"]
111
111
  record.singleton = config["singleton"] unless config["singleton"].nil?
112
+ record.comments_enabled = config["comments_enabled"] unless config["comments_enabled"].nil?
112
113
  record.blueprint = merged_blueprint(record, config)
113
114
  track(result, record, file)
114
115
  end
@@ -207,6 +208,7 @@ module Plum
207
208
  "handle" => config["handle"].to_s,
208
209
  "icon" => config["icon"].presence,
209
210
  "singleton" => !!config["singleton"],
211
+ "comments_enabled" => !!config["comments_enabled"],
210
212
  "route_prefix" => config["route_prefix"].presence,
211
213
  "fields" => config["fields"] || []
212
214
  }
@@ -225,6 +227,7 @@ module Plum
225
227
  "handle" => record.handle,
226
228
  "icon" => record.icon,
227
229
  "singleton" => record.singleton,
230
+ "comments_enabled" => record.comments_enabled,
228
231
  "route_prefix" => record.route_prefix,
229
232
  "fields" => record.fields
230
233
  }
@@ -24,6 +24,11 @@ module Plum
24
24
  }.compact.merge(Plum.content_sources_for(controller, site: site))
25
25
  end
26
26
 
27
+ # Shared layout data without exposing the site's other entries or host sources.
28
+ def layout_context
29
+ { "site" => site_context, "globals" => globals_context, "nav" => nav_context, "forms" => forms_context }
30
+ end
31
+
27
32
  private
28
33
 
29
34
  attr_reader :controller, :site, :entry, :theme_name, :theme_settings
@@ -60,6 +65,9 @@ module Plum
60
65
  "url" => public_entry_path(entry),
61
66
  "data" => entry_data_context(entry, relationship_depth: relationship_depth, expand_blocks: expand_blocks)
62
67
  }
68
+ if Plum.configuration.comments.enabled && entry.plum_comments_enabled?
69
+ ctx["discussion_url"] = "#{controller.request.script_name.to_s.chomp('/')}/discussions/entries/#{entry.id}"
70
+ end
63
71
  if entry.association(:terms).loaded? && entry.terms.any?
64
72
  ctx["terms"] = entry.terms.group_by { |t| t.taxonomy.handle }.transform_values do |terms|
65
73
  terms.map { |t| { "name" => t.name, "slug" => t.slug, "url" => "/#{t.taxonomy.slug}/#{t.slug}" } }
@@ -30,6 +30,14 @@ module Plum
30
30
  end
31
31
  end
32
32
 
33
+ # Render a theme fragment through the same inheritance and filter pipeline.
34
+ def render_partial(template_name, context = {})
35
+ path = ThemeResolver.new.find_template(current_theme(context), template_name)
36
+ raise "Template not found: #{template_name}" unless path
37
+
38
+ Liquid::Template.parse(File.read(path)).render(context, filters: [ LiquidFilters ], registers: render_registers(context))
39
+ end
40
+
33
41
  private
34
42
 
35
43
  def current_theme(context)
@@ -56,7 +56,7 @@ module Plum
56
56
  "exported_at" => Time.current.iso8601,
57
57
  "site" => record(site, %w[id name domain theme_name settings theme_settings custom_css]),
58
58
  "site_setting" => site.site_setting && record(site.site_setting, site_setting_fields),
59
- "content_types" => records(site.content_types, %w[id name handle singleton blueprint icon]),
59
+ "content_types" => records(site.content_types, %w[id name handle singleton blueprint icon comments_enabled]),
60
60
  "fieldsets" => records(site.fieldsets, %w[id name handle fields]),
61
61
  "taxonomies" => records(site.taxonomies, %w[id name handle slug]),
62
62
  "terms" => records(site.terms, %w[id taxonomy_id name slug position]),
@@ -76,7 +76,7 @@ module Plum
76
76
  end
77
77
 
78
78
  def entry_record(entry)
79
- record(entry, %w[id content_type_id title slug status data published_at author_name author_email author_gid locale origin_id]).merge(
79
+ record(entry, %w[id content_type_id title slug status data published_at author_name author_email author_gid locale origin_id comments_mode]).merge(
80
80
  "term_ids" => entry.term_ids
81
81
  )
82
82
  end
@@ -167,7 +167,7 @@ module Plum
167
167
  )
168
168
  maps[:sites][source["id"]] = @site.id
169
169
 
170
- import_simple(:content_types, ContentType, %w[name handle singleton blueprint icon])
170
+ import_simple(:content_types, ContentType, %w[name handle singleton blueprint icon comments_enabled])
171
171
  import_simple(:fieldsets, Fieldset, %w[name handle fields])
172
172
  import_simple(:taxonomies, Taxonomy, %w[name handle slug])
173
173
  import_terms
@@ -239,7 +239,7 @@ module Plum
239
239
 
240
240
  def import_entries
241
241
  Array(data["entries"]).each do |source|
242
- entry = Entry.create!(source.slice("title", "slug", "status", "data", "published_at", "author_name", "author_email", "author_gid", "locale").merge(
242
+ entry = Entry.create!(source.slice("title", "slug", "status", "data", "published_at", "author_name", "author_email", "author_gid", "locale", "comments_mode").merge(
243
243
  "site_id" => @site.id,
244
244
  "content_type_id" => mapped!(:content_types, source["content_type_id"])
245
245
  ))
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>{{ entry.title | default: site.seo_title | default: site.name }} - {{ site.name }}</title>
6
+ <title>{{ discussion.title | default: entry.title | default: site.seo_title | default: site.name }} - {{ site.name }}</title>
7
7
  <meta name="description" content="{{ site.seo_description }}">
8
8
  <link rel="icon" href="/icon.svg" type="image/svg+xml">
9
9
  <link rel="stylesheet" href="{{ 'theme.css' | theme_asset_url }}">
@@ -0,0 +1,8 @@
1
+ <article id="comment-{{ comment.id }}" class="plum-comment">
2
+ <header><strong>{{ comment.author_name }}</strong> {% if comment.author_badge != blank %}<span>{{ comment.author_badge }}</span>{% endif %} <time datetime="{{ comment.created_at }}">{{ comment.date }}</time> {% if comment.pinned %}Pinned{% endif %}</header>
3
+ {% if comment.parent_id %}<p>In reply to {{ comment.parent_author_name }}</p>{% endif %}
4
+ {% if comment.video_seconds %}<p>At {{ comment.video_time }}</p>{% endif %}
5
+ <p class="plum-comment-body">{{ comment.body }}</p>
6
+ {% if comment.reply_form_html %}<details><summary>Reply</summary>{{ comment.reply_form_html }}</details>{% endif %}
7
+ {% if comment.report_form_html %}<details><summary>Report</summary>{{ comment.report_form_html }}</details>{% endif %}
8
+ </article>
@@ -0,0 +1,7 @@
1
+ <form method="post" action="{{ discussion.post_url }}" class="plum-comment-form">
2
+ <input type="hidden" name="authenticity_token" value="{{ discussion.csrf_token }}">
3
+ {% if form.parent_id %}<input type="hidden" name="comment[parent_id]" value="{{ form.parent_id }}">{% endif %}
4
+ <label for="comment-body-{{ form.id }}">{% if form.parent_id %}Your reply{% else %}Your comment{% endif %}</label>
5
+ <textarea id="comment-body-{{ form.id }}" name="comment[body]" maxlength="10000" rows="4" required></textarea>
6
+ <button type="submit">{% if form.parent_id %}Send reply{% else %}Send comment{% endif %}</button>
7
+ </form>
@@ -0,0 +1,6 @@
1
+ <form method="post" action="{{ comment.report_url }}" class="plum-comment-report-form">
2
+ <input type="hidden" name="authenticity_token" value="{{ discussion.csrf_token }}">
3
+ <label for="report-reason-{{ comment.id }}">Reason for reporting</label>
4
+ <textarea id="report-reason-{{ comment.id }}" name="report[reason]" maxlength="1000" required></textarea>
5
+ <button type="submit">Send report</button>
6
+ </form>
@@ -0,0 +1,15 @@
1
+ <link rel="stylesheet" href="{{ discussion.stylesheet_url }}">
2
+ <section class="plum-discussion" aria-labelledby="discussion-title">
3
+ {% for notice in discussion.notices %}<p role="status">{{ notice }}</p>{% endfor %}
4
+ {% if discussion.back_url != blank %}<a href="{{ discussion.back_url }}">Back to {{ discussion.title }}</a>{% endif %}
5
+ <h1 id="discussion-title">{{ discussion.title }}</h1>
6
+ <h2>Questions &amp; discussion</h2>
7
+ <p>Ask a question, share a reflection, or join the conversation. Please be kind and stay on topic. Comments may be reviewed before appearing.</p>
8
+ {% if discussion.comments == empty %}<p>No comments to show yet.</p>{% endif %}
9
+ {{ discussion.comments_html }}
10
+ <nav aria-label="Discussion pages">{% if discussion.previous_url %}<a href="{{ discussion.previous_url }}">Previous</a>{% endif %}{% if discussion.next_url %}<a href="{{ discussion.next_url }}">Next</a>{% endif %}</nav>
11
+ {% if discussion.open == false %}<p>This discussion is closed to new comments.</p>
12
+ {% elsif discussion.signed_in %}{{ discussion.form_html }}
13
+ {% else %}<p>Sign in to join the discussion.</p>{% if discussion.login_url != blank %}<a href="{{ discussion.login_url }}">Sign in</a>{% endif %}
14
+ {% endif %}
15
+ </section>
@@ -23,4 +23,5 @@
23
23
  <p><em>{{ entry.data.excerpt }}</em></p>
24
24
  </div>
25
25
  {% endif %}
26
+ {% if entry.discussion_url %}<p class="plum-discussion-link"><a href="{{ entry.discussion_url }}">Questions &amp; discussion →</a></p>{% endif %}
26
27
  </article>
@@ -2,4 +2,5 @@
2
2
  <h1>{{ entry.title }}</h1>
3
3
 
4
4
  {{ entry.data.sections }}
5
+ {% if entry.discussion_url %}<p class="plum-discussion-link"><a href="{{ entry.discussion_url }}">Questions &amp; discussion →</a></p>{% endif %}
5
6
  </article>
@@ -14,4 +14,5 @@
14
14
  {% if sections_html != "" %}
15
15
  {{ sections_html }}
16
16
  {% endif %}
17
+ {% if entry.discussion_url %}<p class="plum-discussion-link"><a href="{{ entry.discussion_url }}">Questions &amp; discussion →</a></p>{% endif %}
17
18
  </article>
@@ -17,4 +17,5 @@
17
17
  <p><strong>Summary:</strong> {{ entry.data.excerpt }}</p>
18
18
  </aside>
19
19
  {% endif %}
20
+ {% if entry.discussion_url %}<p class="plum-discussion-link"><a href="{{ entry.discussion_url }}">Questions &amp; discussion →</a></p>{% endif %}
20
21
  </article>
@@ -88,6 +88,9 @@
88
88
  class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/cp/nav_menus') ? 'active' : ''}" %>
89
89
  <%= link_to "Taxonomies", "#{cp_prefix}/taxonomies",
90
90
  class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/taxonomies') ? 'active' : ''}" %>
91
+ <% if Plum.configuration.comments.enabled && Plum.configuration.comments.moderator_resolver.call(controller, current_site) %>
92
+ <%= link_to "Comments", cp_comments_path, class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link" %>
93
+ <% end %>
91
94
  <%= link_to "Forms", cp_form_definitions_path,
92
95
  class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/cp/forms') ? 'active' : ''}" %>
93
96
  </div>
@@ -0,0 +1,5 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Discussion</title><%= csrf_meta_tags %><%= stylesheet_link_tag "plum/comments" %></head>
4
+ <body><main class="plum-discussion"><% flash.each do |_kind, message| %><p role="status"><%= message %></p><% end %><%= yield %></main></body>
5
+ </html>
@@ -0,0 +1,16 @@
1
+ <h1 class="text-2xl font-bold mb-4">Comments</h1>
2
+ <nav class="flex gap-4 mb-6"><% %w[pending approved hidden reported].each do |status| %><%= link_to status.titleize, cp_comments_path(status: status), class: "underline" %><% end %></nav>
3
+ <% if @comments.empty? %><p>No comments in this view.</p><% end %>
4
+ <% @comments.each do |comment| %>
5
+ <article class="border rounded p-4 mb-4">
6
+ <h2 class="font-bold"><%= comment.discussion.commentable&.plum_discussion_title %></h2>
7
+ <p><%= comment.author_name %> · <%= comment.status %> · <%= comment.created_at.to_fs(:short) %></p>
8
+ <p class="whitespace-pre-wrap my-4"><%= comment.body %></p>
9
+ <% comment.reports.where(resolved_at: nil).each do |report| %><p>Report: <%= report.reason %></p><% end %>
10
+ <div class="flex gap-4 flex-wrap"><% %w[approve hide pin unpin resolve_reports].each do |operation| %><% next if operation == "pin" && comment.parent_id.present? %><%= button_to operation.humanize, cp_comment_path(comment), method: :patch, params: { operation: operation }, class: "underline" %><% end %>
11
+ <%= button_to comment.discussion.closed? ? "Reopen discussion" : "Close discussion", cp_close_discussion_path(comment.discussion), method: :patch, params: { closed: !comment.discussion.closed? }, class: "underline" %></div>
12
+ </article>
13
+ <% end %>
14
+ <% current_page = params[:page].to_i.clamp(1, 10000) %>
15
+ <%= link_to "Previous", cp_comments_path(status: @status, page: current_page - 1) if current_page > 1 %>
16
+ <%= link_to "Next", cp_comments_path(status: @status, page: current_page + 1) %>
@@ -9,6 +9,9 @@
9
9
  </div>
10
10
  <% end %>
11
11
 
12
+ <% if Plum.configuration.comments.enabled %>
13
+ <div><%= f.check_box :comments_enabled %> <%= f.label :comments_enabled, "Enable comments by default for this content type" %></div>
14
+ <% end %>
12
15
  <div class="bg-white shadow rounded-lg p-6 space-y-6">
13
16
  <div>
14
17
  <%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -10,6 +10,9 @@
10
10
  </div>
11
11
  <% end %>
12
12
 
13
+ <% if Plum.configuration.comments.enabled %>
14
+ <div><%= f.label :comments_mode, "Discussion" %> <%= f.select :comments_mode, [["Use content type setting", "inherit"], ["Open", "open"], ["Closed (existing comments remain visible)", "closed"], ["Disabled (hide discussion)", "disabled"]] %></div>
15
+ <% end %>
13
16
  <div class="grid grid-cols-1 lg:grid-cols-3 gap-6" style="align-items: start;">
14
17
  <!-- Main content -->
15
18
  <div class="lg:col-span-2 space-y-6">
@@ -0,0 +1,13 @@
1
+ <% if comments.empty? %><p>No comments to show yet.</p><% end %>
2
+ <% comments.each do |comment| %>
3
+ <article id="comment-<%= comment.id %>" class="plum-comment">
4
+ <header><strong><%= comment.author_name %></strong> <% if comment.author_badge.present? %><span><%= comment.author_badge %></span><% end %> <time datetime="<%= comment.created_at.iso8601 %>"><%= comment.created_at.to_date.to_fs(:long) %></time> <%= "Pinned" if comment.pinned? %></header>
5
+ <% if comment.parent %><p>In reply to <%= comment.parent.author_name %></p><% end %>
6
+ <% if comment.video_seconds %><p>At <%= comment.video_seconds / 60 %>:<%= "%02d" % (comment.video_seconds % 60) %></p><% end %>
7
+ <p class="plum-comment-body"><%= comment.body %></p>
8
+ <% if context.author_gid %>
9
+ <% if context.open? && comment.parent_id.nil? %><details><summary>Reply</summary><%= render "plum/discussions/form", context: context, parent: comment %></details><% end %>
10
+ <details><summary>Report</summary><%= form_with url: report_discussion_comment_path(handle: context.handle, subject_id: context.subject.id, id: comment.id), scope: :report do |form| %><%= form.label :reason, "Reason for reporting", for: "report-reason-#{comment.id}" %><%= form.text_area :reason, id: "report-reason-#{comment.id}", required: true, maxlength: 1000 %><%= form.submit "Send report" %><% end %></details>
11
+ <% end %>
12
+ </article>
13
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= form_with url: discussion_comments_path(handle: context.handle, subject_id: context.subject.id), scope: :comment do |form| %>
2
+ <% if parent %><%= form.hidden_field :parent_id, value: parent.id %><% end %>
3
+ <%= form.label :body, parent ? "Your reply" : "Your comment", for: "comment-body-#{parent&.id || 'new'}" %>
4
+ <%= form.text_area :body, required: true, maxlength: 10000, rows: 4, id: "comment-body-#{parent&.id || 'new'}" %>
5
+ <%= form.submit parent ? "Send reply" : "Send comment" %>
6
+ <% end %>
@@ -0,0 +1,19 @@
1
+ <% back_url = @context.config.subject_url_resolver.call(self.controller, @context.subject) %>
2
+ <% if back_url.present? %><%= link_to "Back to #{@context.subject.plum_discussion_title}", back_url %><% end %>
3
+ <h1><%= @context.subject.plum_discussion_title %></h1>
4
+ <h2>Questions &amp; discussion</h2>
5
+ <p>Ask a question, share a reflection, or join the conversation. Please be kind and stay on topic. Comments may be reviewed before appearing.</p>
6
+ <%= render "plum/discussions/comments", context: @context, comments: @comments %>
7
+ <nav aria-label="Discussion pages">
8
+ <%= link_to "Previous", discussion_path(handle: @context.handle, subject_id: @context.subject.id, page: page - 1) if page > 1 %>
9
+ <%= link_to "Next", discussion_path(handle: @context.handle, subject_id: @context.subject.id, page: page + 1) if @comments.size == 50 %>
10
+ </nav>
11
+ <% if !@context.open? %>
12
+ <p>This discussion is closed to new comments.</p>
13
+ <% elsif @context.author_gid %>
14
+ <%= render "plum/discussions/form", context: @context, parent: nil %>
15
+ <% else %>
16
+ <p>Sign in to join the discussion.</p>
17
+ <% login_url = @context.config.login_url_resolver.call(self.controller) %>
18
+ <%= link_to "Sign in", login_url if login_url.present? %>
19
+ <% end %>
@@ -4,6 +4,8 @@ Plum::Engine.routes.draw do
4
4
  delete "logout", to: "sessions#destroy"
5
5
 
6
6
  namespace :cp do
7
+ resources :comments, only: [ :index, :update ]
8
+ patch "discussions/:id", to: "comments#close", as: :close_discussion
7
9
  root "dashboard#show"
8
10
  resources :content_types do
9
11
  post :apply_fieldset, on: :member
@@ -38,6 +40,10 @@ Plum::Engine.routes.draw do
38
40
  get "theme_previews/:handle", to: "theme_previews#show", as: :theme_preview
39
41
  end
40
42
 
43
+ get "discussions/:handle/:subject_id", to: "discussions#show", as: :discussion
44
+ post "discussions/:handle/:subject_id/comments", to: "discussions#create", as: :discussion_comments
45
+ post "discussions/:handle/:subject_id/comments/:id/report", to: "discussions#report", as: :report_discussion_comment
46
+
41
47
  post "forms/:handle", to: "form_submissions#create", as: :form
42
48
  namespace :api do
43
49
  namespace :v1 do
@@ -0,0 +1,48 @@
1
+ class CreatePlumComments < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :plum_content_types, :comments_enabled, :boolean, default: false, null: false
4
+ add_column :plum_entries, :comments_mode, :string, default: "inherit", null: false
5
+
6
+ create_table :plum_discussions do |t|
7
+ t.references :site, null: false, foreign_key: { to_table: :plum_sites }
8
+ t.string :commentable_type, null: false
9
+ t.string :commentable_id, null: false
10
+ t.string :subject_handle, null: false
11
+ t.boolean :closed, null: false, default: false
12
+ t.timestamps
13
+ end
14
+ add_index :plum_discussions, [ :site_id, :commentable_type, :commentable_id ], unique: true, name: "plum_discussion_subject_unique"
15
+
16
+ create_table :plum_comments do |t|
17
+ t.references :discussion, null: false, foreign_key: { to_table: :plum_discussions }
18
+ t.references :parent, foreign_key: { to_table: :plum_comments }
19
+ t.string :author_gid, null: false
20
+ t.string :author_name, null: false
21
+ t.string :author_badge
22
+ t.text :body, null: false
23
+ t.string :status, null: false, default: "pending"
24
+ t.boolean :pinned, null: false, default: false
25
+ t.integer :video_seconds
26
+ t.timestamps
27
+ end
28
+ add_index :plum_comments, [ :discussion_id, :status, :id ]
29
+ add_index :plum_comments, [ :author_gid, :created_at ]
30
+
31
+ create_table :plum_comment_reports do |t|
32
+ t.references :comment, null: false, foreign_key: { to_table: :plum_comments }
33
+ t.string :reporter_gid, null: false
34
+ t.text :reason, null: false
35
+ t.datetime :resolved_at
36
+ t.timestamps
37
+ end
38
+ add_index :plum_comment_reports, [ :comment_id, :reporter_gid ], unique: true
39
+
40
+ create_table :plum_comment_moderations do |t|
41
+ t.references :comment, null: false, foreign_key: { to_table: :plum_comments }
42
+ t.string :moderator_gid, null: false
43
+ t.string :action, null: false
44
+ t.string :previous_status, null: false
45
+ t.timestamps
46
+ end
47
+ end
48
+ end
data/docs/comments.md ADDED
@@ -0,0 +1,140 @@
1
+ # Comments and discussions
2
+
3
+ Plum 0.3 adds optional discussions for CMS entries and application models. The feature is off by default. The new engine migration is required on every upgrade to 0.3.0, even if comments remain disabled:
4
+
5
+ ```sh
6
+ bin/rails plum:install:migrations
7
+ bin/rails db:migrate
8
+ ```
9
+
10
+ ## Enable and connect authentication
11
+
12
+ ```ruby
13
+ Plum.configure do |config|
14
+ config.comments.enabled = true
15
+ # A persisted, GlobalID-compatible account, authenticated by YOUR application.
16
+ # Check email verification, suspension, membership, etc. here as appropriate.
17
+ config.comments.commenter_resolver = ->(controller) {
18
+ user = controller.current_reader
19
+ user if user&.email_verified? && !user.suspended?
20
+ }
21
+ config.comments.author_name_resolver = ->(user) { user.display_name }
22
+ config.comments.login_url_resolver = ->(controller) { controller.main_app.new_session_path }
23
+ config.comments.subject_url_resolver = ->(controller, subject) {
24
+ controller.main_app.lesson_path(subject.slug)
25
+ }
26
+ end
27
+ ```
28
+
29
+ Reader authentication is independent of `Plum.current_user` and never grants CMS access. By default there is no commenter, names display as “Reader,” no badge is added, and new comments require approval. The host must supply a verified account, a safe public display name, and any additional account/IP abuse protection. The built-in write limit is one comment per reader per discussion every ten seconds; it is not a global anti-spam service.
30
+
31
+ Enable comments on a content type in the control panel, or set `comments_enabled: true` on `Plum::ContentType`. Each entry has a `comments_mode`: `inherit`, `open`, `closed` (readable), or `disabled` (hidden). Unpublished/future entries are never publicly accessible through the built-in entry endpoint. Closing a discussion stops new comments and replies; reports remain possible.
32
+
33
+ For restricted content, configure **all** relevant actions. Read authorization is also required for creating and reporting. Moderator authorization deliberately can include unpublished content:
34
+
35
+ ```ruby
36
+ config.comments.entry_authorizer = ->(controller, action, entry) {
37
+ action == :moderate ? controller.can_moderate?(entry) : controller.can_read?(entry)
38
+ }
39
+ ```
40
+
41
+ The callbacks receive `:read`, `:create`, `:report`, or `:moderate`. Enforce parent course visibility, enrollment, paid access, and group membership here: a published lesson alone does not prove access to its course. Site membership is independently checked on every request.
42
+
43
+ ## Application models
44
+
45
+ ```ruby
46
+ class Lesson < ApplicationRecord
47
+ include Plum::Commentable
48
+ belongs_to :site, class_name: "Plum::Site"
49
+
50
+ # Override these methods when your schema differs:
51
+ def plum_comment_site = site
52
+ def plum_discussion_title = title
53
+ def plum_comments_enabled? = published?
54
+ def plum_comments_open? = published? && !archived?
55
+ end
56
+
57
+ Plum.configure do |config|
58
+ config.comments.register :lessons,
59
+ resolve: ->(controller, site, id) { Lesson.where(site: site).find(id) },
60
+ authorize: ->(controller, action, lesson) { controller.allowed?(action, lesson) }
61
+ end
62
+ ```
63
+
64
+ Registration is required. Request parameters never choose arbitrary model classes or resolve arbitrary GlobalIDs. Integer and UUID subject IDs are supported. Each subject has one discussion, with dependent cleanup when the subject is destroyed. Register a single stable handle per model; changing handles requires migrating existing discussion `subject_handle` values. `entries` is reserved for Plum entries.
65
+
66
+ ## Rendering
67
+
68
+ By default, discussions render inside the active Plum theme's `layouts/base.liquid`, inheriting the same theme settings, custom CSS, fonts and colors. Override any of these files in your theme:
69
+
70
+ ```
71
+ templates/comments/show.liquid
72
+ templates/comments/_comment.liquid
73
+ templates/comments/_form.liquid
74
+ templates/comments/_report.liquid
75
+ ```
76
+
77
+ Liquid entry templates receive `entry.discussion_url` when comments are enabled for that entry. The bundled entry templates display a link automatically. Custom themes can use:
78
+
79
+ ```liquid
80
+ {% if entry.discussion_url %}
81
+ <a href="{{ entry.discussion_url }}">Questions &amp; discussion</a>
82
+ {% endif %}
83
+ ```
84
+
85
+ This link is safe in static content; authorization and personalized forms are evaluated on the dynamic discussion page.
86
+
87
+ Normal parent-theme inheritance applies to each template independently, then falls back to the bundled default. You can change the page structure, form layout, empty state, sign-in prompt, wording and styling without changing the gem. The discussion controller still enforces authorization, CSRF, validation and moderation.
88
+
89
+ `discussion` includes `title`, `open`, `signed_in`, `back_url`, `login_url`, `previous_url`, `next_url`, `notices`, `stylesheet_url`, `comments`, `comments_html`, and `form_html`. Each approved comment includes `id`, `body`, `author_name`, `author_badge`, `date`, `created_at`, `parent_id`, `parent_author_name`, `pinned`, `video_seconds`, `video_time`, `reply_form_html`, and `report_form_html`. User-supplied text is **already HTML escaped** before entering Liquid: output it directly as `{{ comment.body }}`, without `escape` or decoding it. The HTML fragments are rendered from the theme's partials. No author account IDs, report details or unpublished comment bodies are exposed.
90
+
91
+ Custom forms must retain the hidden `authenticity_token` with `discussion.csrf_token`, `method="post"`, and the supplied `discussion.post_url` or `comment.report_url`. Field names are `comment[body]`, optional `comment[parent_id]`/`comment[video_seconds]`, and `report[reason]`. Form partials receive `form.id` and `form.parent_id`. Themes can style the stable `.plum-discussion`, `.plum-comment`, `.plum-comment-body`, `.plum-comment-form`, and `.plum-comment-report-form` classes. The baseline CSS inherits fonts/colors and exposes `--plum-comment-border`, `--plum-discussion-title-size`, `--plum-comment-button-background`, and `--plum-comment-button-color` variables. Override the page template to omit the baseline stylesheet entirely.
92
+
93
+ For an application such as Redeemer that renders its public site in Rails ERB, use `config.comments.renderer = :rails`. Override the engine's ERB views/layout in the host (see below) to reuse that application's design system.
94
+
95
+ In a dynamic host Rails view, include `Plum::CommentsHelper` in the host helper module, then:
96
+
97
+ ```erb
98
+ <%= plum_comments_link(@entry) %>
99
+ <%= plum_comments_link(@lesson, handle: "lessons", label: "Ask a question") %>
100
+ ```
101
+
102
+ Links open the engine's accessible discussion page, with forms, one level of replies, pagination, pinned comments and reporting. No JavaScript dependency is required. Replies to hidden/pending parents are hidden too. Bodies are plain text, escaped on output, and limited to 10,000 characters. Optional `comment[video_seconds]` stores a nonnegative integer timestamp (up to seven days); it does not synchronize YouTube comments.
103
+
104
+ Override `app/views/layouts/plum/discussion.html.erb` or `app/views/plum/discussions/{show,_comments,_form}.html.erb` in the host for full branding. The default layout loads `plum/comments.css`. Controller paths and route helpers are engine scoped; use `plum.discussion_path(handle: "entries", subject_id: entry.id)` from a host. The helper uses the mounted engine route proxy.
105
+
106
+ Do not put user-specific comment forms in Liquid/static page caches. Discussion responses use `private, no-store`; the default design links from cached content to a dynamic page. No comments are added to the public content API automatically.
107
+
108
+ ## Moderation
109
+
110
+ The Comments control-panel queue supports pending/approved/hidden/reported views, approval, hiding/restoring, pinning, report resolution, and closing/reopening discussions. Comment moderation actions record actor GlobalID, action, previous status and timestamp. Discussion close/reopen changes are not part of that comment audit trail. A moderator must pass both normal CMS authorization and the separate comments moderator check. Entry/model authorization is applied to each record too.
111
+
112
+ Plum-managed editor/admin accounts can moderate by default. Host-authenticated applications **must explicitly configure** the moderator callback; generic CMS access alone is insufficient:
113
+
114
+ ```ruby
115
+ config.comments.moderator_resolver = ->(controller, site) {
116
+ controller.current_user.can_moderate_comments?(site)
117
+ }
118
+ ```
119
+
120
+ `Plum.current_user` must return a persisted GlobalID-compatible moderator for the audit trail. Optional trusted-author settings:
121
+
122
+ ```ruby
123
+ config.comments.auto_approve = ->(controller, author, subject) { author.trusted? }
124
+ config.comments.author_badge_resolver = ->(controller, author) { "Pastor" if author.pastor? }
125
+ ```
126
+
127
+ Badges, authors and approval status are always assigned by server callbacks, never request parameters. Author name/badge are snapshots. Applications own policies for account deletion, pseudonyms, retention, and removal of historical display names. GlobalIDs are stored as references, not automatically dereferenced when rendering, so deleted accounts do not break threads.
128
+
129
+ ## Notifications and backups
130
+
131
+ After commit, `comment.created.plum` and `comment.updated.plum` events carry `comment_id`, `discussion_id`, `site_id`, and `status`. Subscribe with `ActiveSupport::Notifications` and enqueue your own job or Noticed notification. Fetch the current record and re-check publication and recipient authorization before sending; never email pending/private comments to unauthorized readers. Subscribers should handle their own delivery failures and avoid synchronous mail delivery.
132
+
133
+ ```ruby
134
+ ActiveSupport::Notifications.subscribe("comment.created.plum") do |*args|
135
+ payload = args.last
136
+ CommentModerationNotificationJob.perform_later(payload[:comment_id])
137
+ end
138
+ ```
139
+
140
+ Comments, reports and audit records are application data: include the `plum_discussions`, `plum_comments`, `plum_comment_reports` and `plum_comment_moderations` tables in database backups. Site archive/config exports include comment settings, **not conversations or account identities**. Replacing/deleting a site's content deletes its discussions, so take a database backup before archive replacement. A site archive alone is not a backup of community data.
@@ -0,0 +1,15 @@
1
+ # 0.3.0 release validation
2
+
3
+ Validated October 5, 2026.
4
+
5
+ - Ruby 3.3.1 / Rails 8.0.5: 238 tests, 950 assertions pass on SQLite and PostgreSQL.
6
+ - Browser discussion lifecycle: posting, approval, public display and closing pass (10 assertions).
7
+ - Redeemer mounted-engine consumer on Ruby 4.0.6 / Rails 8.1.3.1: 17 tests, 231 assertions pass. Mobile/desktop discussion pages render without overflow or JavaScript errors.
8
+ - RuboCop: 227 files, no offenses. Importmap audit: no vulnerable versioned packages (Lexxy is unversioned and skipped by the audit).
9
+ - Brakeman: seven existing findings outside comments; no new feature findings.
10
+ - Full legacy CMS browser suite remains red. Failures in blueprint editing, writing mode, uploads and other existing CMS interactions were reproduced in a separate, unchanged 0.2.3 checkout. The new discussion browser test passes independently.
11
+ - Engine/standalone migrations match. A fresh isolated database generated the committed schema; unrelated local analytics schema artifacts were removed. The built gem includes migrations, theme templates, controllers, helpers, styles and documentation.
12
+
13
+ The owner requested independent review through the Claude CLI using `claude-fable-5-1`. Two read-only reviews were performed with that exact model. Initial findings about schema pollution and mandatory upgrade migrations were resolved. The follow-up reviewed theme inheritance, escaping, CSRF, authorization/context disclosure, assets and engine mounting and concluded: “no blockers found” and “0.3.0 is ready to publish.” Suggested polish for renderer configuration, page titles, inherited forms and nil predicates was implemented and tested afterward.
14
+
15
+ Comments remain disabled by default. Database migration is required for every upgrade to 0.3.0. Reader authentication, notification delivery and account policy belong to the host application. Redeemer has branded discussion pages and moderation available, but public reader posting remains disabled until its member-account flow is implemented.
@@ -0,0 +1,43 @@
1
+ module Plum
2
+ module Comments
3
+ class Configuration
4
+ attr_accessor :enabled, :commenter_resolver, :moderator_resolver,
5
+ :author_name_resolver, :author_badge_resolver, :auto_approve,
6
+ :entry_authorizer, :login_url_resolver, :subject_url_resolver
7
+ attr_reader :subjects, :renderer
8
+
9
+ def initialize
10
+ @enabled = false
11
+ @renderer = :theme
12
+ # Deliberately separate from CMS authentication. Hosts supply verified readers.
13
+ @commenter_resolver = ->(_controller) { nil }
14
+ @moderator_resolver = lambda { |controller, _site|
15
+ user = Plum.current_user(controller)
16
+ Plum.configuration.authorize_with == :plum && user.is_a?(Plum::User) && %w[editor admin].include?(user.role)
17
+ }
18
+ @author_name_resolver = ->(_author) { "Reader" }
19
+ @author_badge_resolver = ->(_controller, _author) { nil }
20
+ @auto_approve = ->(_controller, _author, _subject) { false }
21
+ @entry_authorizer = ->(_controller, _action, _entry) { true }
22
+ @login_url_resolver = ->(_controller) { nil }
23
+ @subject_url_resolver = ->(_controller, _subject) { nil }
24
+ @subjects = {}
25
+ end
26
+
27
+ def renderer=(value)
28
+ raise ArgumentError, "Comments renderer must be theme or rails" unless %w[theme rails].include?(value.to_s)
29
+ @renderer = value.to_sym
30
+ end
31
+
32
+ # The resolver must return a record from the host's authorized tenant scope.
33
+ # Neither a model class nor a GlobalID is accepted from the HTTP client.
34
+ def register(handle, resolve:, authorize:)
35
+ key = handle.to_s
36
+ unless key.match?(/\A[a-z][a-z0-9_]*\z/) && key != "entries"
37
+ raise ArgumentError, "Use a lowercase subject handle other than entries"
38
+ end
39
+ @subjects[key] = { resolve: resolve, authorize: authorize }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,4 +1,5 @@
1
1
  require_relative "content_source_registry"
2
+ require_relative "comments/configuration"
2
3
 
3
4
  module Plum
4
5
  class Configuration
@@ -9,7 +10,7 @@ module Plum
9
10
  :powered_by_name, :powered_by_url,
10
11
  :static_cache_enabled, :static_cache_path, :config_path
11
12
  attr_writer :theme_paths
12
- attr_reader :content_sources
13
+ attr_reader :content_sources, :comments
13
14
 
14
15
  def initialize
15
16
  @authorize_with = :plum
@@ -37,6 +38,7 @@ module Plum
37
38
  # nil = default to Rails.root/plum when the tasks are invoked.
38
39
  @config_path = nil
39
40
  @content_sources = ContentSourceRegistry.new
41
+ @comments = Comments::Configuration.new
40
42
  @current_site_resolver = ->(_controller) { Plum::Site.first_or_create_standalone! }
41
43
  @current_user_resolver = lambda { |controller|
42
44
  Plum::User.find_by(id: controller.session[:plum_user_id]) if controller.session[:plum_user_id]
data/lib/plum/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Plum
2
2
  module Version
3
- STRING = "0.2.3"
3
+ STRING = "0.3.0"
4
4
  end
5
5
 
6
6
  VERSION = Version::STRING