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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -1
- data/README.md +5 -1
- data/app/assets/builds/tailwind.css +1 -1
- data/app/assets/stylesheets/plum/comments.css +1 -0
- data/app/controllers/plum/cp/comments_controller.rb +55 -0
- data/app/controllers/plum/cp/content_types_controller.rb +1 -1
- data/app/controllers/plum/cp/entries_controller.rb +1 -1
- data/app/controllers/plum/discussions_controller.rb +46 -0
- data/app/helpers/plum/comments_helper.rb +20 -0
- data/app/models/plum/comment.rb +40 -0
- data/app/models/plum/comment_moderation.rb +7 -0
- data/app/models/plum/comment_report.rb +7 -0
- data/app/models/plum/commentable.rb +25 -0
- data/app/models/plum/discussion.rb +17 -0
- data/app/models/plum/entry.rb +11 -0
- data/app/models/plum/site.rb +1 -0
- data/app/services/plum/comments/context.rb +70 -0
- data/app/services/plum/comments/rate_limited.rb +5 -0
- data/app/services/plum/comments/theme_renderer.rb +66 -0
- data/app/services/plum/config_sync.rb +3 -0
- data/app/services/plum/liquid_context.rb +8 -0
- data/app/services/plum/liquid_renderer.rb +8 -0
- data/app/services/plum/site_archive.rb +4 -4
- data/app/themes/default/layouts/base.liquid +1 -1
- data/app/themes/default/templates/comments/_comment.liquid +8 -0
- data/app/themes/default/templates/comments/_form.liquid +7 -0
- data/app/themes/default/templates/comments/_report.liquid +6 -0
- data/app/themes/default/templates/comments/show.liquid +15 -0
- data/app/themes/default/templates/entries/_default.liquid +1 -0
- data/app/themes/default/templates/entries/landing.liquid +1 -0
- data/app/themes/default/templates/entries/pages.liquid +1 -0
- data/app/themes/default/templates/entries/posts.liquid +1 -0
- data/app/views/layouts/plum/cp.html.erb +3 -0
- data/app/views/layouts/plum/discussion.html.erb +5 -0
- data/app/views/plum/cp/comments/index.html.erb +16 -0
- data/app/views/plum/cp/content_types/_form.html.erb +3 -0
- data/app/views/plum/cp/entries/_form.html.erb +3 -0
- data/app/views/plum/discussions/_comments.html.erb +13 -0
- data/app/views/plum/discussions/_form.html.erb +6 -0
- data/app/views/plum/discussions/show.html.erb +19 -0
- data/config/plum_routes.rb +6 -0
- data/db/engine_migrate/20261005190000_create_plum_comments.rb +48 -0
- data/docs/comments.md +140 -0
- data/docs/releases/0.3.0-validation.md +15 -0
- data/lib/plum/comments/configuration.rb +43 -0
- data/lib/plum/configuration.rb +3 -1
- data/lib/plum/version.rb +1 -1
- 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 & 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>
|
|
@@ -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 & 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 %>
|
data/config/plum_routes.rb
CHANGED
|
@@ -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 & 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
|
data/lib/plum/configuration.rb
CHANGED
|
@@ -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]
|