Threaded comments with a moderation queue in Studio. No third-party embed.
Comments under your blog posts — threaded one level deep, with a moderation queue the site owner works through in Studio. Comments are stored in your own database and rendered by your own frontend: no Disqus iframe, no tracking scripts, no third-party account.
Comments are a content type. New comments arrive as drafts (the moderation queue); approving one is just publishing it, which is the only way it becomes publicly readable — the content API never exposes drafts. Two custom routes handle the public surface: one accepts new comments with honeypot + rate limiting, one returns the approved thread for a post.
You are adding native comments to a Prelo project's blog.
1. **Content type.** In `cms.config.js`:
```js
comment: {
label: "Comments",
fields: {
post: { type: "text", required: true }, // slug of the post commented on
author: { type: "text", required: true }, // display name
email: { type: "text", required: true }, // never rendered publicly
body: { type: "text", required: true },
reply_to:{ type: "number" }, // parent comment id, one level max
},
studio: { columns: ["post", "author", "body"] },
},
```
Draft = pending moderation. Published = approved and visible. Delete = rejected/spam.
2. **Rate limiting.** `hooks.boot` creates `x_comment_hits (ip TEXT, at INTEGER)`; limit 3 comments per 10 minutes per IP, pruning old rows each request.
3. **Routes** (`routes:` in `cms.config.js`, mounted under `/api/x/`):
- `"POST /comments"` — validate: `post` matches an existing **published** post slug (look it up; 404 otherwise), `author` ≤ 80 chars, `email` well-formed, `body` 1–4000 chars, `reply_to` (if set) is an existing **published** comment on the same post whose own `reply_to` is empty (enforces one-level threading). Honeypot field `website`: silently accept and discard. Insert as a draft `comment` row (title = author, fields in the JSON column, matching the shape the content API writes). Respond `{"ok":true,"queued":true}`.
- `"GET /comments/:slug"` — return published comments for the post, oldest first, as `{id, author, body, reply_to, at}` — **never include email**. This route exists because the public generic API would also work (`GET /api/comment` returns published only) but would leak the email field; the custom route is the field filter.
4. **Frontend.** Under each post:
- Render the thread from `GET /api/x/comments/{slug}`: top-level comments in order, replies nested one level, relative timestamps, author name in the project's text styles. Escape everything — comment bodies are plain text, render newlines as paragraphs, no HTML pass-through.
- A comment form (name, email, comment, hidden `website` honeypot) posting to `/api/x/comments`. Success state: "Thanks — your comment is awaiting moderation." Keep input on failure. A "Reply" link under each top-level comment reveals the same form with `reply_to` set.
- Server-render the thread where the frontend architecture allows so comments are crawlable.
5. **Optional notification.** If SMTP env is configured, email the site owner on each new pending comment (subject: post title). Skip silently otherwise.
6. **Verify.** `prelo check`; post a comment, see it as a draft in Studio, publish it, confirm it renders and that `GET /api/comment/{id}` public response is the only other exposure (and decide with the user whether to keep that type public — if not, note the email-leak consideration above is already handled by the custom route).Moderation is the Studio list: pending comments are drafts, publish to approve, delete to reject. The owner can edit a comment's body before approving (typos, stripping a URL) like any other content.
Instant client-side search across pages and posts, indexed at build time.
Events as a content type with a monthly calendar view and iCal feed.
Contact form with spam protection and email notifications, plus a submissions inbox in Studio.