# Design note — pages

**Dated 2026-08-04. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

The third of the three high-priority requests, and the largest. As with the
theme editor, this starts by saying what is not in it.

---

## Scope, up front

The roadmap asks for five things. Four land here:

1. ✅ Block-based page builder — rich text, heading, image, embed, HTML,
   topic list. **Not** an arbitrary "custom query" block; see below
2. ✅ Menu and permission integration; a **width** choice rather than
   arbitrary layouts
3. ⏸ **Custom databases: define fields, records, list and detail views,
   per-field permissions**
4. ✅ Draft → publish with scheduling
5. ✅ SEO controls per page, through the existing `Seo` builder

**Custom databases are not in this pass.** "Define your own content type with
its own fields, its own permissions, its own list and detail views" is a
product, not a feature — it is most of a CMS on its own, and it needs its own
note about field types, storage shape, indexing and how per-field permissions
interact with search. Bolting a thin version onto pages would produce
something that has to be thrown away.

**There is no custom-query block.** A block that runs SQL an administrator
typed is a way to take the site down with a cartesian join, and a way to read
every password hash on the box the moment an admin account is borrowed. What
people actually want from it is "show me some topics, filtered" — so that is
what the topic-list block does, with real controls for forum, tag, prefix,
sort and count. If something genuinely needs a query, that is an extension,
where the trust is explicit.

## The job to be done

A forum needs pages that are not topics: rules, an about page, a landing page,
a page of links. Somebody wants to build one, see it before anyone else does,
and put it in the menu.

## Decision 1 — pages get real URLs, which needs a router change

`/rules` is the URL somebody wants. `/page/rules` is what is easy.

Matching a bare `/{slug}` in the ordinary way would shadow every route
registered after it, and module route order is dependency order — so whether
`/search` worked would depend on which modules happened to load first. That is
the kind of bug that appears months later on somebody else's install.

So the router gains **fallback routes**: registered separately, and consulted
only after every ordinary route has failed to match. That is a small addition
and it is the honest one — a page is genuinely the last thing to try.

Two more guards on top:

- **A slug that collides with a real route is refused at save time**, naming
  the route it clashes with. Fallbacks mean `/forums` would never reach the
  page, so a page at that slug would silently do nothing.
- **Reserved prefixes are refused** — `admin`, `api`, `assets` and the like —
  because those are where future routes will go and a page squatting there is
  a trap for whoever adds them.

## Decision 2 — a page is an ordered list of typed blocks

`cv_page_blocks`: page, type, position, a JSON settings bag, and a content
column for the large text. Six types:

| Type | What it is | The interesting part |
|---|---|---|
| `heading` | A section heading | Level 2–4; the page title is the h1 |
| `rich` | TipTap rich text | Same editor and same renderer as a post, so it cannot drift from how posts look |
| `image` | An image | Alt text is a field, not an afterthought |
| `embed` | A video | **Allowlisted providers only** |
| `html` | Raw HTML | Admin-trust, stated plainly |
| `topics` | A list of topics | Real filters, not SQL |

**Embeds are allowlisted, not arbitrary iframes.** An `<iframe>` field is an
invitation to frame anything, including a page that then talks to this one. So
the block takes a URL, recognises YouTube and Vimeo, and builds the embed URL
itself from the extracted id. A URL it does not recognise is refused with a
sentence saying which providers work — and the HTML block is there for
somebody who genuinely needs something else and understands what they are
doing.

**Every block escapes on render.** The two that do not are `rich`, which goes
through the same `TipTapRenderer` posts use, and `html`, which is the one that
says so.

## Decision 3 — draft, published, scheduled, and the schedule is not a job

Three states. Scheduled carries a `publish_at`.

There is no queue and no cron in Convoro, so **a scheduled page becomes visible
by being asked for after its time**, not by something waking up to publish it.
The resolver treats "scheduled and `publish_at` has passed" as published, and
the admin list says so. One rule, no moving parts, and no page that quietly
fails to appear because a worker died.

The cost is that "published_at" as a stored fact lags reality until somebody
looks. That is worth it, and it is written down here so nobody later
"fixes" it by adding a scheduler that then has to be run.

## Decision 4 — visibility reuses the menu's model exactly

Group rows per page; no rows means everybody; a signed-out visitor counts as
being in the guest group. The same shape as menu items, deliberately: two
different vocabularies for the same idea is how an administrator ends up
guessing.

**A page hidden from a group returns 404, not 403.** A 403 confirms the page
exists, which is information the person was not meant to have. The exception
is an administrator, who sees a draft or a hidden page with a banner saying
which it is — being unable to look at what you are building is worse.

## Decision 5 — SEO is per page, and defaults sensibly

Title, meta description, and an indexable flag feeding the existing `Seo`
builder. A draft or scheduled page is `noindex` regardless of the flag, because
a search engine finding an unfinished page is exactly what nobody wants.

## Decision 6 — the editor saves the whole page at once

Blocks are added, reordered and removed in the browser, and the whole
arrangement posts together — the same decision as the menu editor and for the
same reason: rearranging is something people do and then think better of, so
there has to be something to discard.

New blocks are built from a `<template>` in the page rather than assembled in
JavaScript, so the markup for a block lives in one place and the editor cannot
drift from what the server renders.

## What this does not do

- **No custom databases.** See the top.
- **No arbitrary SQL.** See the top.
- **No revisions.** Themes have them because a theme is one small document; a
  page is a list of rows and versioning it properly means versioning the
  blocks too. Worth doing, not worth doing badly.
- **No nested blocks, columns or grids.** Layout is theming.
- **No file uploads in the image block yet** — it takes a URL, and the
  attachment system already stores files somewhere with its own permissions.
  Wiring the two together is a real job rather than a checkbox.
