103 lines
5.0 KiB
Markdown
103 lines
5.0 KiB
Markdown
# ADR-0022: Editorial content is Markdown, not a page builder
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-08-13
|
|
|
|
## Context
|
|
|
|
The store needs a journal and a handful of static pages — returns policy,
|
|
shipping, about. That is a small requirement with a large gravitational pull:
|
|
every CMS starts as "just some pages" and ends as a block tree, because the
|
|
first time an editor wants two columns, someone adds a two-column block.
|
|
|
|
Once that happens the storefront's design stops being code. Layout decisions
|
|
move into the database, the React components become a rendering engine for
|
|
whatever an editor assembled, and the careful type scale and spacing this
|
|
project has spent seven milestones establishing become suggestions. That is the
|
|
outcome this entire project exists to avoid — it is why it is not WooCommerce.
|
|
|
|
## Decision
|
|
|
|
**One `ContentEntry` with a `type` of `PAGE` or `POST`,** rather than two
|
|
tables. The shared surface — per-locale slug, title, body, SEO, publish state,
|
|
soft delete — is nearly all of it. What differs is placement: a POST is listed
|
|
in a feed newest-first and carries an excerpt and a cover; a PAGE is addressed
|
|
directly and never listed. Same reasoning as ADR-0020.
|
|
|
|
**The body is one Markdown column.** No blocks, no tree, no layout. An editor
|
|
chooses _what it says_; the storefront decides _what it looks like_.
|
|
|
|
**Markdown is rendered to React elements, never to an HTML string.**
|
|
`react-markdown` parses to a component tree, so `dangerouslySetInnerHTML`
|
|
appears nowhere in this path. A `<script>` in a body is escaped text because it
|
|
never becomes a tag — injection is structurally impossible rather than
|
|
sanitised away, which is the same move as anchoring reviews to order lines
|
|
(ADR-0021).
|
|
|
|
**Every element is mapped explicitly.** Unstyled `<h2>`/`<p>` inheriting
|
|
browser defaults is precisely how a CMS page ends up looking like a different
|
|
website. Body `<h1>` is demoted to `<h2>` because the page already renders the
|
|
title as `<h1>`, and internal links go through the locale-aware `Link` — a raw
|
|
`<a href="/men">` in a Vietnamese post drops the locale prefix, a bug already
|
|
fixed three times elsewhere in this storefront.
|
|
|
|
**Images in bodies are dropped.** They would bypass the media library, hotlink
|
|
to arbitrary hosts, and arrive without dimensions — a layout shift on every
|
|
page they appear on. Posts get one cover image, from the media library, with
|
|
known dimensions.
|
|
|
|
**`publishedAt` is stamped once, on first publish, and never rewritten.**
|
|
Re-stamping on save would jump a post to the top of the feed because somebody
|
|
fixed a typo, and would silently change a date a reader may already have cited.
|
|
|
|
**Slugs are per-locale**, like products, because `/en/blog/how-to-layer` and
|
|
`/vi/blog/cach-phoi-do` are the SEO surface. A slug from any locale resolves,
|
|
then redirects to the canonical one for the locale being browsed.
|
|
|
|
**Pages live at `/pages/[slug]`.** Not at the locale root: a catch-all there
|
|
would compete with `/men`, `/cart` and `/search`, and make every genuine 404
|
|
ambiguous. `/pages/returns` is uglier than `/returns` and cannot silently
|
|
shadow a real route.
|
|
|
|
## Consequences
|
|
|
|
An editor writes in Markdown. That is a real constraint on non-technical staff,
|
|
and the correct one at this size — the alternative is a rich-text editor whose
|
|
output must then be sanitised, which is a larger surface than the whole feature.
|
|
|
|
Publishing is one decision with two buttons: _Save draft_ and _Publish_. A
|
|
status dropdown plus Save reads as neither, and publishing is the consequential
|
|
action.
|
|
|
|
Deleting is soft, and also unpublishes. A soft-deleted row still marked
|
|
`PUBLISHED` is one forgotten `deletedAt: null` away from being live again.
|
|
|
|
Translations are replaced wholesale on save rather than upserted per locale, so
|
|
removing the English version of a post actually stops `/en/blog/<slug>`
|
|
resolving instead of serving the copy the editor just deleted.
|
|
|
|
The `/content/{posts,pages}/slugs` endpoints return titles alongside slugs.
|
|
They serve three callers — `generateStaticParams`, the footer's page list, and
|
|
a sitemap when one is built — which is cheaper than three endpoints returning
|
|
the same rows.
|
|
|
|
Homepage blocks are **not** built. The module's original sketch mentioned them;
|
|
they are the exact feature that turns this into a page builder, and the
|
|
homepage is better served by code until there is a concrete editorial need.
|
|
|
|
## Alternatives considered
|
|
|
|
**A block/section tree.** Maximum editorial flexibility, and it hands layout to
|
|
the database. Rejected — see Context.
|
|
|
|
**MDX with embedded components.** Lets a post drop in a product carousel, and
|
|
makes content executable code that must be built and deployed. Rejected for
|
|
content authored through an admin UI at runtime.
|
|
|
|
**HTML stored directly with sanitisation.** Familiar to editors, and every
|
|
sanitiser is a denylist that someone eventually gets past. Rendering to React
|
|
elements needs no denylist.
|
|
|
|
**Separate `pages` and `blog_posts` tables.** Clearer names, two admin screens,
|
|
and two sets of publishing rules that must agree forever.
|