Files
RenewCMS/docs/design/localization-tables.md
T

24 KiB

Localization board tables

Imported from source/locale-spec.js. Read localization.md first: its explicit corrections supersede conflicting text below. API additions are proposals to be specified and accepted through /slice, not existing endpoints.

Decisions

Question Chosen behavior Rationale Existing behavior affected Backend dependency Release
Language model One story group, one article id per language, one version per locale Reuses the article editor, permissions and publish flow None for single-language sites group_id, lang on articles; unique (group_id, lang) Initial
Default vs editing vs source Three separate roles. Default is a site setting; editing language is the open article; source is chosen per translation Default is not always the best source None source_article_id per version Initial
Locale scope Per site, list of enabled languages plus one default Matches site-level settings Settings → Sites gains Languages site_languages table Initial
Site locale field Four distinct values: formatting locale (existing Site.Locale, still used for dates and numbers), default content language (new, seeded from the full Site.Locale and confirmed by the owner), article language (per version), social metadata locale (explicit or derived, never guessed) Avoids silent reinterpretation ResolveArticleSEO reads Site.Locale for social metadata (verify in repo); for localized articles it uses the article language None Initial
Disable vs remove Disable keeps content and published versions. Remove only at zero versions No configuration action deletes or unpublishes None Count per language; 409 language_in_use Initial
Existing content Setup assigns a language per site, in one transaction; each article becomes its own story Metadata only; no guessed grouping Articles gain lang Bulk assign endpoint, atomic Initial
Titles Editable after creation. A title change never alters an existing slug. Slug changes only through explicit Edit slug; no redirects are promised (none in the backend) Translated titles must be revisable; URLs stay stable. Same rule on single-language sites CHANGE: titles were immutable. Slug policy applies everywhere Allow title in PUT; slug rules Initial
Slug uniqueness Unique per (site, lang) Same slug in two languages is normal Today unique per site Index change Initial
Language control Dropdown beside the title; rows show version state; interface language unchanged Compact for 2 to 20 languages Editor heading translations[] on GET article Initial
Switching Opens another article id. Dirty form asks Keep editing, Discard, Save and switch URL is the state; no lost work None None Initial
Add translation Draft only; copy or start empty; images/authors reused; copied text flagged Needs review Copying is a start, not completion None POST translations; 409 language_exists Initial
Source reference Optional read-only pane; segment on mobile; no sync Keeps the single editor default None Read access to source Initial
Direction dir on editor and reference panes only Shell stays LTR None Per-language dir from locale Initial
List: all languages One row per story; title from default version, else first accessible; coverage chips Avoids duplicates Articles list Grouped query, access-filtered Initial
List: language selected One row per version; Not in French tab lists stories Units always stated Tabs and counts lang, tab params Initial
Search All languages: every accessible title. Language: that language only Predictable Search Access-filtered Initial
Publishing Per version; copy always names the language; no bulk publish Prevents accidental scope Publish button copy None Initial
Deleting Per version. Last version deletes the story. Story delete needs permission on all Explicit scope Delete dialog None Initial
Freshness Needs review when the source revision changes after the reviewed revision; Mark as reviewed records it Reliable, not timestamp-based None content_revision on versions; reviewed_source_revision Initial
Revision-bumping fields Title, body, excerpt, slug, image alt text, SEO and social text. Not authors, cover, site, publication Only translatable content counts None Revision incremented in same write Initial
Site move Moves the whole story; destination must enable every language; slug conflicts block No splitting or merging Site select in editor Move endpoint, validation Initial
Hidden versions Titles, counts, coverage, language summaries, previews, errors and deletion confirmations name only versions the user can read No leakage List counts, delete dialog Filter at query level Initial
Machine translation Not included Out of scope None None Later
Cross-site stories Not included Out of scope None None Later
Bulk publish / review Not included No clear need None None Later
Slug policy Slug generated once at creation. Changed only by Edit slug, before or after publishing. Published change warns: "Links to the old address will stop working. RenewCMS does not create redirects." One policy, no redirect promise Slug field Unique (site, lang) Initial
Permission model Capabilities per version: read, edit, publish, delete, create-translation, review. UI renders from capabilities[] on the article response, not from role names Matches role/scope permissions Editor, list capabilities[]; server enforces every call Initial
Missing vs hidden "Not in French" lists stories where French is absent or unreadable to the user. Add French translation on a story with an unreadable French returns a neutral 409 (see Visibility policy) Discovery cannot leak existence List Conflict check ignores read permission; response is neutral Initial
Source-copy access Read access to the chosen source is rechecked at submit, inside the transaction Access can change while the dialog is open Add translation Neutral 403/404 Initial
Disabled languages Versions stay readable and editable; publishing blocked until re-enabled; filter lists them under Disabled No silent unpublishing Site languages enabled flag per site language Initial

Capability matrix (per version, enforced server-side)

Case Read Edit Publish Delete Create translation Review UI result
P1 Read-only version Yes No No No If can create on site No Banner "You can read this version. You can't edit it." No Save, Publish, Delete; formatting toolbar hidden; fields read-only; source reference still available
P2 Edit, no publish Yes Yes No Per scope Per scope If can edit Save shown; Publish hidden; hint "Publishing needs the publish permission"
P3 Publish, no edit Yes No Yes Per scope Per scope No Publish/Unpublish shown, fields read-only (only if existing permissions allow publish without edit)
P4 Sibling readable, not editable Yes No No No n/a n/a Opens read-only; row tag "Read only"
P5 Sibling not readable No No No No n/a n/a Row absent; no count, no placeholder
P6 Can create, not modify all Yes Own versions Own Own Yes Own Story delete and move disabled: "You can't delete this story." Delete this version stays available if allowed
Owner / Admin Yes Yes Yes Yes Yes Yes All readable versions; not inferred from role label alone

Field ownership, copy and fallback

Field Own per version Copied on "Copy from source" Fallback if empty
Title Yes Entered by the editor None (required)
Slug Yes Generated from the new title None
Body Yes Yes, flagged Needs review None
Excerpt Yes Yes First words of body
SEO title and description Yes Yes Title / excerpt, then site defaults
Social text Yes Yes SEO text
Image alt text Yes Yes None
Cover and inline images Reference, files shared Yes, no duplicate files None
Authors Yes Yes, editable None
Site Shared by the story Same None
Publication and timestamps Yes No None

API: existing vs proposed

Capability Status Request Errors
List articles Existing, extended GET /v1/article/?page=&size=&q=&status=&site=&seo= (existing). Proposed: lang, tab=all / missing / review (same names as the URL; no separate mode or needs_review) 400 invalid page/size (SPA never shows it)
Get article Existing, extended GET /v1/article/{id}. Proposed fields: lang, story_id, capabilities[], translations[] (readable only), source { article_id, revision, status: tracked / inaccessible / deleted } 404 for hidden or deleted
Create article Existing, extended POST /v1/article/. Proposed: lang, story_id, source_article_id, copy (routes to translation rules) 409 language_unavailable · 422 language_not_enabled
Edit Existing, extended PATCH /v1/article/{id}. Proposed: if_revision for overwrite recheck 409 revision_conflict { current_revision }
Publish / unpublish Existing PATCH /v1/article/{id}/publish, /unpublish. Per version 403 missing_permission · 422 language_disabled
Delete Existing DELETE /v1/article/{id}. Last version deletes the story 403 · 404
Add translation Proposed POST /v1/article/{id}/translations { lang, title, source_article_id, copy }. Path id is any readable version; source_article_id selects the copy source; source_revision captured in the transaction 409 language_unavailable (neutral) · 404 (neutral) · 422 · 403
Mark reviewed Proposed POST /v1/article/{id}/review { source_article_id, source_revision, translation_revision } 409 source_changed · translation_changed · source_replaced · 403
Change tracked source Proposed PATCH /v1/article/{id}/source { source_article_id / null } 422 source_cycle · 422 source_other_story · 403
Site languages Proposed GET/PUT /v1/site/{key}/languages { default, languages[{ lang, enabled }] }, atomic 422 default_not_enabled · no_enabled_language · 409 language_in_use
Setup Proposed GET/POST /v1/language/setup { sites:[{ key, lang }] }, idempotent per site key; articles created during setup get the site default; retry after failure is safe 403 · 409 setup_in_progress
Move story Proposed POST /v1/story/{story_id}/move { site }, atomic, revalidates 409 slug_conflict · 422 language_not_enabled · 403 (neutral)
Publish with unsaved edits Client sequence PATCH /v1/article/{id} (save) then PATCH /v1/article/{id}/publish. The second call is sent only if the first returns 200. No new endpoint Save failure: nothing published, edits stay unsaved
Public article (two requests) Proposed Exact: GET /v1/public/site/{key}/lang/{lang}/article/{slug}. Resolve: GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. Existing article-ID links unchanged See Public lookup

Accessibility and interaction

Control Accessible name and keyboard Focus Announcement
Language control "Language: French. Switch version". Enter or Down opens a menu; arrows move; Enter selects; Escape closes Into list on open; back to control on close Polite: "Editing French version"
Add translation dialog Focus trapped; Escape cancels unless creating First field; back to the control Polite: "French draft created"
Source segment (mobile) Two-button radiogroup, arrows switch Stays on the segment Polite: "Showing English source"
Coverage chips aria-label per chip: "French: draft, needs review" Row, not chips None
Dialogs on mobile Bottom sheet, grabber, 44 px targets As dialogs elsewhere None
Reduced motion No slide transitions on popover or sheets

Acceptance scenarios

# Given / When / Then
1 Given a single-language site, when creating an article, then no language control appears anywhere.
2 Given French is enabled, when an editor adds a French translation with "Copy from English", then a draft is created, the editor opens it, and the badge reads Draft and Needs review.
3 When French is published, then English publication is unchanged.
4 When English changes title or body after review, then French shows Needs review; changing authors does not.
5 Given dirty French, when choosing English, then a three-choice dialog appears and no work is lost.
6 Given a writer who cannot see the German draft, then German appears nowhere in lists, counts, menus or alternates.
7 When disabling German with 12 versions, then all 12 stay and 3 published stay online.
8 When two editors create French at once, then the second sees "French is no longer available" with entries kept.
9 When moving a story to a site missing a language, then the move is blocked and nothing splits.
10 When the interface language is Arabic or French, the editing language indicator still shows the article language.
11 Given French has unsaved edits, when the user chooses Save and publish French and the save fails, then nothing is published and the edits stay unsaved with Try saving again.
12 Given French is missing and unpublished, when a client resolves the story with lang=fr and fallback=default and the default is published, then the response is the default language with requested_lang fr and fallback true; with the default also unpublished it is 404 language_not_available.
13 Given a site locale pt-BR, when setup seeds the default language, then it reads Portuguese (Brazil) pt-BR and the owner confirms it before anything applies.

Existing surfaces audit and verification

Item Note
Home Recent articles show the default-language title with a language code when not default. No other change.
Activity Events gain the language: "Published French version of …". Existing events unchanged.
Article menu Publish and Delete labels name the language on multilingual sites.
API URL copy Copies the version URL with ?lang= on multilingual sites.
SEO preview Shows the localized slug and canonical.
Media usage Counts a shared image once; lists versions that use it.
Verification limit Repository files (docs/design, docs/api/articles.md, EditorView.vue, ArticleView.vue, NewArticleModal.vue, ArticleDetails.vue, useArticleDraft.ts, articleDomain.go, siteDomain.go, ResolveArticleSEO.go, features/*.feature) were not accessible. Statements about them are assumptions marked verify in repo. The .claude/design paths do not exist; the same files are at the project root.
Decided Slug policy, hidden-version disclosure, Home counting stories, and review storing source_revision only (no text diff) are decided in the other tables.

Visibility and anti-enumeration policy

Situation User can read it User cannot read it
Duplicate translation "French already exists for this story." Action: Open French version "French can't be created for this story." No Open action, no title, no hint a version exists
Create conflict response 409 language_exists with article_id 409 language_unavailable; identical body for exists-unreadable, disabled and lost race
Story delete confirmation Names readable versions: "Delete English and French?" Unreadable versions are not named or counted. If any blocks deletion: "You can't delete this story." plus Delete this version
Source unavailable Deleted: "The source was deleted." "The source isn't available to you."
Missing translation tab Row shown; Add French translation Same row; a 409 shows the neutral copy
Counts and summaries Readable versions only Never included

Public lookup and URLs

Topic Decision
CMS API vs website CMS API URLs (/v1/...) are never shown as website URLs. Website URLs come from a proposed per-site public_url_pattern (owner), e.g. https://example.com/{lang}/{slug}. Existing convention unverified.
Pattern not configured "Public URL not configured" with a link to site settings. No URL is fabricated. API links by article ID remain.
Exact lookup GET /v1/public/site/{key}/lang/{lang}/article/{slug}. Matches site + language + slug only (slug is unique per site and language). Missing or unpublished: 404 article_not_found. It never falls back and never tries another language's slug
Resolution by story GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. story_id is the stable story identifier, returned on every public response and in alternates[]. No lang means the site default language. Fallback is decided only by story_id
Example: requested version published story st_01, lang=fr, French published: 200, lang fr, fallback false
Example: requested missing or unpublished lang=fr, no published French. Without fallback: 404 language_not_available. With fallback=default and a published default (en): 200, lang en, requested_lang fr, fallback true
Example: default also missing or unpublished lang=fr, fallback=default, French and the default both unavailable: 404 language_not_available. No third language is tried
Example: default requested but unpublished lang=en, English unpublished, fallback=default: 404 language_not_available
Compatibility Article-ID endpoints (GET /v1/article/{id}) and existing public URLs keep working. Setup labels existing articles with the site default language
Response identity Always lang (actual language). alternates[] lists only published, enabled-language versions
Canonical The localized page itself, unless an explicit canonical override is set
Default-language change Does not change what existing URLs return; absent or unpublished default gives 404 unless fallback is requested

Locale semantics

Value Meaning Used for
Site formatting locale Existing Site.Locale SPA dates and numbers; kept
Default content language New per-site setting. Setup seeds it from the full Site.Locale when a supported language entry matches (en-GB, pt-BR). If only the language part is supported it seeds that and says so. No locale: the owner must choose. The owner confirms or changes it explicitly; nothing applies unconfirmed Fallback, new-article default, setup
Article language BCP 47 tag per version (en, fr, pt-BR, ar) html lang, API lang, hreflang
Social metadata locale Resolved in order: 1) explicit social locale on the site language (set by an owner, e.g. fr: fr_FR); 2) the language tag already has a region (pt-BR gives pt_BR); 3) Site.Locale has the same language part (site en-GB and article en give en_GB); 4) otherwise none: og:locale and og:locale:alternate are omitted for that version and the editor shows Not set. No region is invented for a bare language. og:locale:alternate lists other published versions whose locale resolves by the same rules; the rest are left out og:locale, og:locale:alternate
SEO fallbacks Empty French SEO fields fall back to French title and excerpt. English site defaults are never applied silently to a non-default language: editor shows "Empty. English site default not used." and the tag is omitted Editor labels: "Fallback: French title", "No fallback"

Article list modes and sample data

Mode Units and count Representative title Row opens Search Sort
All languages Stories Default-language version if readable, else first readable by language code Representative version Any readable title Story updated: latest readable version
Language selected Versions in that language That version That version That language only Version updated
Missing language Stories lacking it or unreadable in it As All languages Representative version Titles in any readable language Story updated
Filters Status, Author, SEO, Needs review: a story matches if any readable version matches (language mode: the version)
Story Author, Updated Union of readable authors; latest readable update
Differing sites Language filter lists the union; Missing excludes sites that do not enable the language
Dataset (locale-fixtures.js) Journal: 8 stories, 17 versions, page size 5. Enabled en (default), fr, de, ar; disabled es, it. Versions: en 7 (6 published), fr 4 (2 published, 2 Needs review), de 4 (3 published), ar 1, es 1, it 0. 4 stories have no French version. Needs translation 7, Needs review 2. Every list, tab count, summary and pager is computed from this one fixture. Separate fixtures: Press (3 stories, all translated, nothing-missing state), Atelier (8 languages, long labels), Journal before French (prototype start).

Review source and revision semantics

Topic Decision
Record { article_id, source_article_id, source_revision, translation_revision, reviewed_by, reviewed_at }
Confirmation guard Dialog carries translation_revision. If either revision moved: 409 source_changed or translation_changed; dialog says "This changed since you opened it. Review again." Nothing is marked
Change tracked source Explicit action Change tracked source. Result: Needs review. Earlier reviews are kept as history
Cycles Prevented (422 source_cycle). Source must be in the same story
Access loss vs deletion Access lost: "The source isn't available to you right now." history kept, Mark reviewed disabled. Deleted: "Source deleted" with Choose a new source
Source replacement Pick another readable version of the story; freshness becomes Needs review
What changed Changed-field summary only (Title, Body, Excerpt, SEO) from field-level revision flags. No text diff this release; label "Changed fields"
Concurrent edits Mark reviewed applies only to the revisions the user saw

Title, slug and concurrent edit flows

Case Behavior
Title editing Inline input in heading style. States: pristine, editing, invalid ("Enter a title"), saving, failed (value kept), unsaved. Accessible label "Title (French)"
Slug See Slug policy. Edit slug lives in Search and sharing
Conflict "Someone else saved this version." Choices: Keep my changes (nothing sent), Download my version (title and body as text), Save over theirs (sends if_revision with the revision fetched now; a newer save gives a new 409), Reload theirs (confirm: "This discards your unsaved changes.")
Overwrite scope "Replaces the title, body and search fields of this version only. Other languages are untouched."
Session expiry Draft kept locally per article id (title, body, search fields, base revision) until saved or discarded. After login: "Restored unsaved changes from

Language configuration lifecycle

Action Rule
Disable default Blocked: "Choose another default first."
Disable last enabled Blocked: "At least one language must stay enabled."
Disable with content Allowed; versions remain; published stay online; publishing blocked for drafts
Re-enable Row action Re-enable; no data change
Remove Only at zero versions; never the default
Save error Dialog keeps language, default and toggle values; error above the footer
Story moves Disabled-language versions count toward destination requirements

Mobile list and prototype scope

Item Spec
Filter sheet Site, Status, Author, SEO, Language; Apply, Clear all; chips with "Remove filter Status: Draft"
Tabs Scrolls with edge fade and buttons "Scroll tabs left/right"; Not in French always reachable
Pager Same pager and page size as desktop; URL keeps page and size
Prototype flows Wired in RenewCMS Prototype, flow Localization: F1 enable French, F2 create translation, F3 edit beside English, F4 save and publish French, F5 edit English, F6 freshness warning, F7 review, F8 missing tab, F9 switch with unsaved, F10 delete one version, F11 restricted user. Starts from the Journal-before-French fixture