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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/README.md +10 -2
  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 +4 -1
  35. data/app/views/layouts/plum/discussion.html.erb +5 -0
  36. data/app/views/layouts/plum/session.html.erb +1 -1
  37. data/app/views/layouts/plum/write.html.erb +1 -1
  38. data/app/views/plum/cp/comments/index.html.erb +16 -0
  39. data/app/views/plum/cp/content_types/_form.html.erb +3 -0
  40. data/app/views/plum/cp/entries/_form.html.erb +3 -0
  41. data/app/views/plum/discussions/_comments.html.erb +13 -0
  42. data/app/views/plum/discussions/_form.html.erb +6 -0
  43. data/app/views/plum/discussions/show.html.erb +19 -0
  44. data/config/plum_routes.rb +6 -0
  45. data/db/engine_migrate/20261005190000_create_plum_comments.rb +48 -0
  46. data/docs/comments.md +140 -0
  47. data/docs/plum-cli.md +66 -0
  48. data/docs/releases/0.3.0-validation.md +15 -0
  49. data/docs/zero-to-agency.md +172 -0
  50. data/lib/generators/plum/install/install_generator.rb +13 -1
  51. data/lib/plum/comments/configuration.rb +43 -0
  52. data/lib/plum/configuration.rb +3 -1
  53. data/lib/plum/engine.rb +11 -4
  54. data/lib/plum/version.rb +1 -1
  55. data/lib/plum.rb +7 -0
  56. 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 &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.
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
- route %(mount Plum::Engine, at: "#{options[:mount_path]}")
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
@@ -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
- initializer "plum.importmap", before: "importmap" do |app|
34
- if app.config.respond_to?(:importmap)
35
- app.config.importmap.paths << root.join("config/plum_importmap.rb")
36
- end
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
@@ -1,6 +1,6 @@
1
1
  module Plum
2
2
  module Version
3
- STRING = "0.2.2"
3
+ STRING = "0.3.0"
4
4
  end
5
5
 
6
6
  VERSION = Version::STRING
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