222 lines
24 KiB
Markdown
222 lines
24 KiB
Markdown
# 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 <time>." |
|
|
|
|
## 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 |
|