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

5.0 KiB

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.