plum-cms 0.2.2 → 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 +78 -0
- data/README.md +10 -2
- 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 +4 -1
- data/app/views/layouts/plum/discussion.html.erb +5 -0
- data/app/views/layouts/plum/session.html.erb +1 -1
- data/app/views/layouts/plum/write.html.erb +1 -1
- 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/plum-cli.md +66 -0
- data/docs/releases/0.3.0-validation.md +15 -0
- data/docs/zero-to-agency.md +172 -0
- data/lib/generators/plum/install/install_generator.rb +13 -1
- data/lib/plum/comments/configuration.rb +43 -0
- data/lib/plum/configuration.rb +3 -1
- data/lib/plum/engine.rb +11 -4
- data/lib/plum/version.rb +1 -1
- data/lib/plum.rb +7 -0
- metadata +36 -4
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.
|
data/docs/plum-cli.md
CHANGED
|
@@ -84,12 +84,78 @@ accepted since the content is public. The host key persists at
|
|
|
84
84
|
`~/.config/plum/tutorial_host_ed25519` so returning visitors don't get
|
|
85
85
|
host-key warnings.
|
|
86
86
|
|
|
87
|
+
## plum new — zero to running site
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
plum new client-site
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Scaffolds a deploy-ready Plum site: `rails new`, the plum-cms gem, the
|
|
94
|
+
install generator mounted at `/`, a prepared database, and a git history.
|
|
95
|
+
It bootstraps its own toolchain the way connect bootstraps a server — no
|
|
96
|
+
Ruby? It offers to install mise, then Ruby, then Rails, each step
|
|
97
|
+
confirm-gated. It also refuses (clearly) to run inside an existing Rails
|
|
98
|
+
app, and registers the new project in the global registry.
|
|
99
|
+
|
|
100
|
+
## plum deploy — no registry, no CI
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
plum deploy [remote]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Ships the site with nothing but local Docker and SSH: builds the image
|
|
107
|
+
locally (`--platform linux/amd64`), streams it to the server through the
|
|
108
|
+
SSH connection (`docker save | gzip | ssh docker load` — no registry
|
|
109
|
+
account anywhere), then has Once roll the app to it (`once update
|
|
110
|
+
--image`, or `once deploy --host <once_app>` on first deploy) and waits
|
|
111
|
+
for the container's health check to go green. Auto-update is switched
|
|
112
|
+
off for these apps: there's no registry to poll, so the site changes
|
|
113
|
+
when you deploy it — never by surprise. Currently `via: once` remotes.
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
plum deploy --preview
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Ships the same image as a **separate Once app under a preview hostname** —
|
|
120
|
+
the agency workflow: share the work-in-progress with a client before
|
|
121
|
+
their domain points anywhere. With `preview_host:` set on the remote in
|
|
122
|
+
plum.yml (e.g. `client.preview.your-agency.com` behind a wildcard A
|
|
123
|
+
record) you get clean branded preview URLs; with nothing configured it
|
|
124
|
+
auto-derives `<app>.<server-ip>.sslip.io` — magic wildcard DNS that
|
|
125
|
+
resolves to your server with zero setup. Preview and production are
|
|
126
|
+
independent apps, so previews never touch a live site.
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
plum promote [remote]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Go-live for a preview, in place: re-homes the preview app onto the
|
|
133
|
+
production domain (`once update --host`) — same container, same
|
|
134
|
+
database, all the content built during the preview — with TLS
|
|
135
|
+
provisioned on the spot. Preflights that a preview exists, that the
|
|
136
|
+
production hostname isn't already a separate app, and that DNS reaches
|
|
137
|
+
the server (confirm to override for proxied DNS).
|
|
138
|
+
|
|
139
|
+
The full story — one droplet, several client sites, honest costs — is
|
|
140
|
+
[the zero-to-agency guide](zero-to-agency.md).
|
|
141
|
+
|
|
87
142
|
## Two layers of configuration
|
|
88
143
|
|
|
89
144
|
This mirrors the Firebase CLI's model deliberately, because it solves the
|
|
90
145
|
same two problems: "how does this directory reach its server" and "how do I
|
|
91
146
|
work with a project without cd-ing into its directory first."
|
|
92
147
|
|
|
148
|
+
Two words, two meanings — keep them straight:
|
|
149
|
+
|
|
150
|
+
- A **project** is a site: `the-final-word`, `client-site`. That's what
|
|
151
|
+
the registry, `plum use`, and `--project NAME` refer to.
|
|
152
|
+
- A **remote** is one *environment* of a site: `production`, `staging`.
|
|
153
|
+
That's the positional argument on commands — `plum pull production`,
|
|
154
|
+
`plum logs staging` — and the keys under `remotes:` in plum.yml.
|
|
155
|
+
|
|
156
|
+
So `plum pull --project client-site staging` means "the staging
|
|
157
|
+
environment of the client-site project."
|
|
158
|
+
|
|
93
159
|
1. **`plum.yml`** — lives in a site's own repo, defines its **remotes**
|
|
94
160
|
(servers). This is the source of truth for how to reach that one site.
|
|
95
161
|
2. **The global project registry** (`~/.config/plum/config.yml`, or
|
|
@@ -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,172 @@
|
|
|
1
|
+
# Zero to Agency
|
|
2
|
+
|
|
3
|
+
How to go from an empty directory to hosting client sites — with one
|
|
4
|
+
cheap VPS, the `plum` CLI, and nothing else. No GitHub required, no
|
|
5
|
+
container registry, no CI pipeline, no platform-as-a-service bill. Your
|
|
6
|
+
laptop builds, your server runs, SSH connects them.
|
|
7
|
+
|
|
8
|
+
The stack under the hood: Rails + Plum in a Docker container, managed by
|
|
9
|
+
[Once](https://once.com) (37signals' single-container deploy tool) behind
|
|
10
|
+
its bundled TLS-terminating proxy, on any Linux box. The examples use
|
|
11
|
+
DigitalOcean; any VPS provider works the same way.
|
|
12
|
+
|
|
13
|
+
## What you need
|
|
14
|
+
|
|
15
|
+
- A [DigitalOcean](https://digitalocean.com) account (or any VPS provider)
|
|
16
|
+
- Docker on your laptop (Docker Desktop or Docker Engine) — `plum deploy`
|
|
17
|
+
builds images locally
|
|
18
|
+
- The `plum` CLI
|
|
19
|
+
- Ruby and Rails — and if you don't have them, `plum new` offers to
|
|
20
|
+
install both for you (via [mise](https://mise.jdx.dev))
|
|
21
|
+
|
|
22
|
+
## 1. Create the server
|
|
23
|
+
|
|
24
|
+
In DigitalOcean: **Create → Droplet**. Ubuntu LTS, the $6/month size is
|
|
25
|
+
enough to start (pick $12 for headroom). Add your SSH key if you have one
|
|
26
|
+
— if you don't, that's fine: `plum connect` will generate one and offer
|
|
27
|
+
to copy it over with the root password DigitalOcean emails you. Note the
|
|
28
|
+
droplet's IP.
|
|
29
|
+
|
|
30
|
+
That's the last time you touch a cloud dashboard.
|
|
31
|
+
|
|
32
|
+
## 2. Create the site
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
plum new client-site
|
|
36
|
+
cd client-site
|
|
37
|
+
bin/rails server
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You have a running Plum site at `http://localhost:3000` — the control
|
|
41
|
+
panel is at `/cp`. Build the client's theme in `app/themes/`, model
|
|
42
|
+
their content types in the CP (or as YAML in `plum/` — see
|
|
43
|
+
`docs/config-as-code.md`), and load in their content.
|
|
44
|
+
|
|
45
|
+
## 3. Connect the server
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
plum connect 203.0.113.5 # your droplet's IP
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The wizard handles the entire first-contact ritual: SSH keys, passwordless
|
|
52
|
+
login, detecting that this is a Once-shaped app, and — on a fresh droplet —
|
|
53
|
+
**installing Docker Engine and Once for you** (each with your
|
|
54
|
+
confirmation). When it asks for the app hostname, give it the client's
|
|
55
|
+
real domain (`clientname.com`): that's how the server routes requests and
|
|
56
|
+
provisions TLS.
|
|
57
|
+
|
|
58
|
+
## 4. Point DNS
|
|
59
|
+
|
|
60
|
+
At the client's DNS: an **A record** for their domain (and `www`) to the
|
|
61
|
+
droplet's IP. **This must happen before the first deploy** — Once
|
|
62
|
+
verifies `https://<domain>` end-to-end as the final step of a first
|
|
63
|
+
deploy and rolls back if the domain doesn't reach the server yet
|
|
64
|
+
(`plum deploy` checks this up front and warns you). Proxied DNS
|
|
65
|
+
(Cloudflare in Full strict mode) is fine.
|
|
66
|
+
|
|
67
|
+
Building ahead of a domain cutover? That's what previews are for:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
plum deploy --preview
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This ships the site as a *separate* Once app under a preview hostname —
|
|
74
|
+
by default `<app>.<server-ip>.sslip.io`, magic wildcard DNS that reaches
|
|
75
|
+
your server with zero configuration, so you have a shareable client
|
|
76
|
+
preview URL before any real DNS exists. For branded previews, point one
|
|
77
|
+
wildcard record (`*.preview.your-agency.com`) at the droplet and set
|
|
78
|
+
`preview_host: client.preview.your-agency.com` on the remote in
|
|
79
|
+
plum.yml.
|
|
80
|
+
|
|
81
|
+
Iterate on the preview as long as you like — build the content, tune the
|
|
82
|
+
theme, let the client click around. Then, when they flip DNS to the
|
|
83
|
+
droplet:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
plum promote
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The preview app *becomes* the site: same container, same database, every
|
|
90
|
+
word of content you built during the preview — it simply starts answering
|
|
91
|
+
to the real domain, with TLS provisioned on the spot. No rebuild, no
|
|
92
|
+
content migration, no separate go-live deploy. (If the site should live
|
|
93
|
+
on its own droplet instead, `plum connect` it there and `plum deploy` —
|
|
94
|
+
site archives carry the content across.)
|
|
95
|
+
|
|
96
|
+
## 5. Deploy
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
plum deploy
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Watch it go: the image builds on your laptop, streams to the server over
|
|
103
|
+
SSH (`docker save | docker load` — this is why no registry account
|
|
104
|
+
exists in this guide), Once starts it behind the proxy, and the CLI waits
|
|
105
|
+
for the container to report healthy. `https://clientname.com` is live.
|
|
106
|
+
|
|
107
|
+
Deploys are explicit — the site changes when you run `plum deploy`,
|
|
108
|
+
never by surprise. Ship again any time with the same command.
|
|
109
|
+
|
|
110
|
+
## 6. The agency rhythm
|
|
111
|
+
|
|
112
|
+
Day-to-day, the CLI is your relationship with every client site:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
plum pull # production content → your laptop, any DB engine
|
|
116
|
+
plum push # content-model changes (plum/ YAML) → production
|
|
117
|
+
plum check # did anyone drift the model? (CI-friendly)
|
|
118
|
+
plum logs --follow # heroku-style tail, prettified
|
|
119
|
+
plum backup # timestamped archive on the server
|
|
120
|
+
plum run -- TASK # any rake task, any remote
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Content pulls down, structure pushes up. Clients edit in `/cp` on
|
|
124
|
+
production; you develop locally against a full copy of their real
|
|
125
|
+
content. Turn on **static caching** (`docs/static-caching.md`) and the
|
|
126
|
+
site serves rendered pages from disk — SQLite happily runs a
|
|
127
|
+
production site on the smallest droplet, because cached reads never
|
|
128
|
+
touch the database at all.
|
|
129
|
+
|
|
130
|
+
## 7. The second client
|
|
131
|
+
|
|
132
|
+
This is where it becomes an agency:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
plum new second-client
|
|
136
|
+
cd second-client
|
|
137
|
+
plum connect # the host prompt offers your existing server
|
|
138
|
+
plum deploy
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Once runs each site in its own container on the same box, routing by
|
|
142
|
+
hostname, each with its own TLS certificate. A $6–12 droplet comfortably
|
|
143
|
+
hosts several small client sites; each new client is marginal-cost-zero
|
|
144
|
+
until the box runs hot. Manage the fleet from anywhere:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
plum projects list # every site you run
|
|
148
|
+
plum use second-client # switch the active project
|
|
149
|
+
plum pull --project client-site # or target one explicitly
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## 8. When you outgrow one box
|
|
153
|
+
|
|
154
|
+
- **More sites than one droplet likes**: create a second droplet,
|
|
155
|
+
`plum connect` the new sites there. The registry doesn't care how many
|
|
156
|
+
servers are behind it.
|
|
157
|
+
- **A client needs Postgres** (heavy dynamic features): switch that app's
|
|
158
|
+
database and redeploy — `plum pull` still works across engines.
|
|
159
|
+
- **Backups off the box**: `plum backup` writes archives on the server;
|
|
160
|
+
Once's auto-backup handles scheduling. Pull copies down with `plum pull`
|
|
161
|
+
or scp them to storage you control.
|
|
162
|
+
|
|
163
|
+
## Costs, honestly
|
|
164
|
+
|
|
165
|
+
| Thing | Cost |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| Droplet (several small sites) | $6–12/mo |
|
|
168
|
+
| Domains | the client's problem |
|
|
169
|
+
| Registry, CI, PaaS, per-seat CMS pricing | $0 — none exist in this setup |
|
|
170
|
+
|
|
171
|
+
The whole model: you charge for design and content strategy; the
|
|
172
|
+
infrastructure is a rounding error.
|
|
@@ -30,7 +30,19 @@ module Plum
|
|
|
30
30
|
def mount_engine
|
|
31
31
|
return if options[:skip_route]
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
# Mounted at "/", Plum serves every path the app doesn't claim — so
|
|
34
|
+
# it must come AFTER the app's own routes (notably `get "up"`, the
|
|
35
|
+
# health check load balancers and deploy tooling probe). Rails'
|
|
36
|
+
# `route` helper prepends, which would shadow them; append at the
|
|
37
|
+
# bottom of the block instead.
|
|
38
|
+
inject_into_file "config/routes.rb", before: /^end\s*\z/ do
|
|
39
|
+
<<~RUBY.indent(2)
|
|
40
|
+
|
|
41
|
+
# Plum last: mounted at #{options[:mount_path].inspect} it serves every path
|
|
42
|
+
# the app doesn't claim, so app routes (like /up) must come first.
|
|
43
|
+
mount Plum::Engine, at: #{options[:mount_path].inspect}
|
|
44
|
+
RUBY
|
|
45
|
+
end
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
def print_next_steps
|
|
@@ -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]
|
data/lib/plum/engine.rb
CHANGED
|
@@ -30,10 +30,17 @@ module Plum
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
# Build a Plum-scoped importmap instead of merging Plum's pins into the
|
|
34
|
+
# host app's global importmap. Plum's layouts render this map (host JS stack
|
|
35
|
+
# + Plum's additions), while the host app's own pages keep a clean importmap
|
|
36
|
+
# free of Plum's controllers/lexxy/activestorage pins. Runs after the host's
|
|
37
|
+
# importmap is configured so we can copy its paths.
|
|
38
|
+
initializer "plum.importmap", after: "importmap" do |app|
|
|
39
|
+
next unless defined?(Importmap::Map) && app.config.respond_to?(:importmap)
|
|
40
|
+
|
|
41
|
+
Plum.importmap = Importmap::Map.new
|
|
42
|
+
Array(app.config.importmap.paths).each { |path| Plum.importmap.draw(path) }
|
|
43
|
+
Plum.importmap.draw(root.join("config/plum_importmap.rb"))
|
|
37
44
|
end
|
|
38
45
|
end
|
|
39
46
|
end
|
data/lib/plum/version.rb
CHANGED
data/lib/plum.rb
CHANGED
|
@@ -3,6 +3,13 @@ require_relative "plum/content_source"
|
|
|
3
3
|
require_relative "plum/configuration"
|
|
4
4
|
|
|
5
5
|
module Plum
|
|
6
|
+
class << self
|
|
7
|
+
# A Plum-scoped importmap (host pins + Plum's own pins), used only by Plum's
|
|
8
|
+
# layouts. Keeps Plum's controllers/lexxy/activestorage out of the host
|
|
9
|
+
# app's global importmap so a mounted Plum can't pollute the host's JS.
|
|
10
|
+
attr_accessor :importmap
|
|
11
|
+
end
|
|
12
|
+
|
|
6
13
|
def self.table_name_prefix
|
|
7
14
|
"plum_"
|
|
8
15
|
end
|