Files
web_sport/docs/adr/0022-editorial-content-is-markdown-not-a-page-builder.md
2026-08-13 23:20:23 +07:00

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.