Files
RenewCMS/docs/design/decisions.md
T

12 KiB
Raw Blame History

Design decisions

Product and interface decisions that outlive the design passes. Behaviour comes from features/; where the two disagree, stop and ask. Screen copy and states are in screens/*.md, states.md and routes.md.

Search and sharing (SEO)

Interface

Placement. A "Search and sharing" section sits under "Site and authors" in the Editor sidebar and in the mobile Details sheet. It is collapsed by default, shows a one-line summary ("Ready", "2 suggestions" or "Hidden from search") and is remembered per browser like Site and authors. It holds only the slug, the full URL and three actions: "Edit search and sharing", "What the frontend gets" and the suggestion list. The fields and live previews open in a slide-over (desktop) or a 92% bottom sheet stacked over the Details sheet (mobile), because the 280 px sidebar is too narrow for previews and the slide-over rule already covers forms with a live preview. The API preview is a 720 px dialog (desktop) or a bottom sheet (mobile).

Principles. RenewCMS does not render pages or inject meta tags. Every field is optional and the UI shows its fallback. No scores. Suggestions never block saving or publishing; publishing is allowed regardless. Suggestions are dismissible per article.

Fallback chain (resolved server-side, shown in the UI).

Output Order
title seo_title, then article title; site title_template applied last
description seo_description, then excerpt, then first sentence of the body, then site default_description
image og_image_id, then cover_image_id, then site social image
canonical canonical_url, then site base_url + "/" + slug
robots noindex true gives "noindex, nofollow"; otherwise site default_robots
og/twitter title and description social_title, social_description, then the resolved values above

Soft ranges (counters, never blocks). Meta title 30–60 characters; description 70–160; slug "very long" above 75 characters. Counter turns neutral-warning (--ui-warning) outside the range, never red.

New article fields (PATCH /v1/article/{id}, all optional, snake_case). slug, excerpt (max 300), seo_title (max 120), seo_description (max 320), cover_image_id, cover_image_alt (max 200; the cover is "ready" only with alt text), social_title, social_description, social_image_id, canonical_url (absolute https URL), noindex (boolean; only sets a flag in the API, it doesn't change visibility), seo_dismissed (string[] of suggestion codes). Slug: lowercase letters, numbers, hyphens; unique per site; 409 slug_taken with suggestion ("-2"); auto-generated from the title until edited.

New site fields (PATCH /v1/site/{id}, owner only). site_name, title_template (default "%title% | %site%", must contain %title%), default_description (max 320), social_image_id (existing), default_robots ("index" | "noindex"; existing as robots).

New endpoints.

  • GET /v1/article/{id}/seo: resolved object, public for online articles, 404 for drafts to anonymous callers. Shape: { title, description, canonical_url, robots, og: { type, title, description, image, image_alt, url, site_name, locale }, twitter: { card, title, description, image, image_alt }, image: { url, alt, source }, authors: [{ name, url, same_as: [] }], published_at, modified_at, fallbacks: { title, description, image, canonical }, json_ld }. json_ld is a ready Article object (headline, description, image, author[] with name, url and sameAs from author profile URL and social links, datePublished, dateModified, mainEntityOfPage).
  • GET /v1/site/{key}/sitemap?page=&size=: published URLs only, total in X-Total-Count. Items: { url, modified_at, noindex }. Decision: yes, shown on Integration under "Search and sharing" with a copy button; noindex articles are included with the flag so the frontend can skip them.
  • GET /v1/article/?seo=ready|suggestions|hidden: filter behind "Needs attention" (suggestions) with seo_status and seo_suggestions (count) added to list items.

Articles list. Quiet indicator inside the Status cell (and under the title on mobile cards): nothing when ready, a lightbulb chip "N" for suggestions, an eye-off chip "Hidden" for noindex. Toolbar gets a "Needs attention" toggle next to search; tabs and counts are unchanged. It behaves the same on a single-site install (independent of the Site filter, which stays hidden).

Authors. No change to the Authors screen. Profile URL (website) and social links feed authors[].url and same_as, and JSON-LD author[].sameAs; the API preview shows them.

Permissions. Anyone who can edit an article edits its SEO fields. Only the owner edits site SEO defaults. 401 and 403 rows join the existing HTTP table: 403 owner_only on Site SEO fields, 401 on a missing session.

Left out on purpose. Keyword scoring and focus keywords, readability scores, redirects management, schema types beyond Article, hreflang, robots.txt and sitemap rendering (RenewCMS only provides data), automatic image generation for social cards.

Decisions

# Question Decision
1 Is slug immutable after publication? No. It stays editable. RenewCMS does not manage redirects, so the frontend owns them. No extra warning copy is added to the design.
2 SEO object for drafts GET /v1/article/{id}/seo answers drafts to authenticated callers who can read the article, and 404 to anonymous callers. The editor preview needs it.
3 Noindex in the sitemap Included with noindex: true, as drawn. The frontend decides.
4 Who applies title_template The API. title in the SEO object is resolved. The raw seo_title and the site template stay readable on their own routes.
5 Backfill Migration generates a slug for every existing article from its title (same slug rule as authors), unique per site, with -2, -3 suffixes. A title with no letter or digit becomes article, then article-2, article-3. It runs once and is idempotent.
6 Excerpt in the list payload Yes. Article items carry excerpt.

Implicit decisions

  • Suggestions come from one server-side rule set. The same function feeds seo_status and seo_suggestions in the article list, the collapsed section summary, and the editor panel.
  • The editor panel's "checking" states and the "slug taken" state use one non-persisting endpoint: POST /v1/article/{id}/seo/check. Its body takes the same optional SEO fields as PATCH /v1/article/{id}, plus an optional body (never site_id or author_ids), and overrides the saved values for the check only. It answers { status, suggestions: [{ code, field, params }], slug: { available, suggestion } }. Copy for each code lives in the SPA, with a French entry. seo_dismissed codes are filtered out of suggestions and are never counted in the list.
  • The suggestion codes are the ones the board uses: title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short.
  • Cover, social and site social images are media usages: GET /media/{id}/usage, used_in, the unused filter, merge, purge and delete protection must count them. This also closes the existing gap where author avatars and site social images are missing from GET /media/{id}/usage.
  • Roles: anyone who can edit an article edits its SEO fields and runs the check. Only the owner edits site SEO fields (403 "Only the owner can do this."). GET /v1/site/{key}/sitemap is public, like the other read routes that serve online articles. SEO edits are logged as an articles event; no field values or body text are stored.
  • PATCH /v1/article/{id} still requires at least one known field and still answers 400 on null. The SEO fields join body, site_id and author_ids.

Sites and authors

Who can do what

Action Owner Admin Other roles
Create, edit, set default, delete a site yes no no
Read the site list, choose an article's site yes yes yes, if they can edit the article
Create, edit, delete any author yes yes no
Read authors, pick authors on an article yes yes yes
Create and edit their own linked author (POST /v1/author/me) yes yes yes
Create an author from the editor ("Create author…", "This is me") yes yes no

Decisions

  • One site per article (site_id on the article). No per-site publish state: is_online stays the global switch. API keys stay global.
  • The site key is immutable after creation. Exactly one default site exists and it cannot be deleted. Deleting a site with articles requires reassign_to.
  • Authors are separate from users. An optional, unique link to a user. The slug is globally unique and not tied to a site. Zero authors on an article is allowed and never blocks publishing. Deleting an author with articles requires reassign_to (an author id or none).
  • Media used as an author avatar or a site's default social image counts as media usage.
  • The default site is seeded once from PUBLIC_URL. That variable is the CMS's own address (email links), so the seed is only a starting value that the owner edits. The base URL must be https, except for loopback hosts.
  • Existing articles are backfilled to the default site and get no authors.

Roles and permissions

Event names follow the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove.

Decisions

# Decision made Alternative
1 Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. General first, as in the brief.
2 Writer gets Authors: view and edit own, and Activity: own. No Authors or Activity access.
3 A new role starts with nothing granted; presets fill the grid. Start from a copy of Writer.
4 Own-scope rows others created show "View only" with a lock icon, not a hidden row. Hide other people's entries.
5 Reset to default exists for built-in roles edited by the owner. No reset.
6 Activity own/all scope uses the same None / Own / All control. Toggle plus a separate scope select.
7 Transfer ownership takes effect at once, no email to the new owner. The new owner must accept.
8 A user with Roles: manage cannot edit their own role, even in a custom role. Allow narrowing only.

Account self-service

Authored content is anonymized to "Deleted user". The email-change link lasts 2 hours. One export succeeds per user every 15 minutes, and a direct download is the export flow. "Last administrator" counts Owner. A change request for an address already in use answers like success. The account-deleted email goes to the address the account had.

Interface defaults

  • Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet.
  • Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover --ui-text, active --ui-text-toned; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%).
  • Article pagination: Shipped shapes: the article list body is {items, total, page, size, counts}; the media list body is an array with the total in X-Total-Count. Users and authors follow their own routes (see routes.md).
  • The Remove action in the link popover has no confirmation; undo restores the link.
  • The link button is disabled when an image is selected.
  • The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS.
  • Mobile toolbar scrolls sideways; no wrap, no overflow menu.
  • Selected nav items keep the sand fill plus weight 500 and aria-current (the fill alone is 1.3:1 in light).
  • Syntax colouring dropped from code samples (one rendering).
  • Export "Download started" offers only Close.
  • New article keeps the title counter "n / 200" (title is limited to 200); the body has no limit and no counter.
  • Page title 20/28, page padding 24/16, card padding 16 (foundations.md).
  • Activity position in the sidebar follows AppFrame.
  • The Prototype shows a toast "Not in this prototype" for sidebar items it has no screen for (Media, Activity, Settings).