33 KiB
Article versions, translations, revisions and story move
Contract for features/article_translation_create.feature, features/article_edit_revision.feature and features/article_story_lifecycle.feature. The SPA mocks /v1 from this page. The article object itself is described in articles.md, the languages of a site in site-languages.md.
Vocabulary
- A story groups the language versions of one article inside one site. A version is one article row: it has its own id,
lang, title, slug, content, publication andcreated_by. A story holds at most one live version per language. revisionis the edit counter of a version, starting at1, raised by1per saved change (see What raises revision). An internal content counter is also kept; it is never exposed on a version, only assource.revision.- A reader of a version is a caller who passes the existing read rule: an online version is readable by everyone, an offline one by a verified caller whose
content.readcovers it (all, orownand the caller created it). - Missing means no readable version. Everything an article read says about sibling versions (translations, source, error bodies, headers, counts) is limited to the versions the caller can read. No count, placeholder, total, header or error text ever names, counts or hints at a version the caller cannot read.
Errors
Errors are plain text. The body is the code (or an existing message) followed by a line feed, and extra data travels in response headers. A refused request changes nothing.
| Header | Set on | Value |
|---|---|---|
X-Current-Revision |
409 revision_conflict |
the current revision of the version, an integer |
X-Existing-Article-Id |
409 language_exists |
id of the readable version that already holds the language |
X-Conflicting-Articles |
409 slug_conflict |
ids of the readable versions whose address is taken at the destination, comma separated, in creation order. Absent when none of them is readable |
X-Missing-Languages |
422 language_not_enabled on a move or on PATCH site_id |
tags missing or disabled at the destination, comma separated, sorted, readable versions only. Absent when empty |
X-Slug-Suggestion |
409 slug_taken |
first free <slug>-N (existing) |
X-Missing-Permission |
403 missing_permission |
the permission key (existing) |
The headers are set on those responses only. Same-origin SPA code reads them directly; cross-origin scripts do not see them (Access-Control-Expose-Headers is unchanged).
| Status | Body | Meaning |
|---|---|---|
| 400 | The request cannot be processed due to a mismatch in the format of the body. |
undecodable or invalid body, missing copy, copy: false with a source, if_revision invalid, an unknown or empty update |
| 400 | The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). |
invalid id in the path |
| 400 | the validator text (for example Key: 'PostTranslation.Title' Error:Field validation for 'Title' failed on the 'min' tag) |
lang or title missing, title shorter than 3 or longer than 200 characters on a create, site that is "" or not a site key on POST /v1/article/ |
| 400 | Article title must be between 3 and 200 characters. |
a PATCH title outside 3 to 200 runes |
| 400 | invalid_slug |
a PATCH slug that is not a slug |
| 401 | You must be logged in to access this resource. |
no valid session or key |
| 403 | missing_permission |
the caller lacks the permission (X-Missing-Permission) |
| 404 | Article not found |
the version, or the story, does not exist or the caller cannot read it |
| 404 | source_unavailable |
the chosen source does not exist or is not readable; the same answer for both |
| 404 | site_not_found |
POST /v1/story/{story_id}/move, or POST /v1/article/ with site, naming an unknown site key |
| 409 | revision_conflict |
if_revision is not the current revision (X-Current-Revision) |
| 409 | language_exists |
the language already has a readable version (X-Existing-Article-Id) |
| 409 | language_unavailable |
one neutral answer: the language is disabled on the site (the chosen site on POST /v1/article/ with site), or its version exists but is not readable by the caller, or a concurrent request created it first |
| 409 | setup_required |
the version has no language yet (lang ""): the site default is not confirmed (the chosen site on POST /v1/article/ with site) |
| 409 | slug_taken |
a PATCH slug used by another article of the same site and language (X-Slug-Suggestion) |
| 409 | slug_conflict |
a move would put an address that already exists at the destination (X-Conflicting-Articles) |
| 409 | story_move_required |
PATCH site_id on a version whose story holds several versions |
| 409 | target_site_deleted |
POST /v1/article/: the target site was deleted after it was looked up and before the article was stored; choose another site |
| 409 | story_busy |
the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry |
| 422 | invalid_language |
the tag is not a language tag |
| 422 | unsupported_language |
a valid tag outside the supported list |
| 422 | language_not_enabled |
the site (the chosen one on POST /v1/article/ with site) has no language row for the tag (creation), or a destination site lacks or has disabled a language of the story (move, X-Missing-Languages) |
| 422 | language_disabled |
publishing an offline version whose language is disabled on its site |
| 422 | source_other_story |
the source belongs to another story |
Fields added to an article read
Added to the article object of GET /v1/article/{id}, POST, PATCH, publish, unpublish, and for revision also to each item of GET /v1/article/. Every other key keeps its name, value and type.
{
"id": 10,
"lang": "en",
"story_id": 5,
"revision": 3,
"capabilities": ["read", "edit", "publish", "delete", "create_translation"],
"translations": [
{"id": 10, "lang": "en", "title": "Renovation costs", "is_online": true, "language_enabled": true, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]},
{"id": 13, "lang": "ar", "title": "Takalif", "is_online": true, "language_enabled": true, "capabilities": ["read", "create_translation"]},
{"id": 12, "lang": "fr", "title": "Couts", "is_online": false, "language_enabled": false, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]}
],
"source": {"article_id": 10, "revision": 4}
}
revisionis a positive integer on every read and write, public reads included.capabilitieslists what the caller may do with this version, always in this order and always starting withread:edit(content.updatecovers it),publish(content.publish),delete(content.delete),create_translation(the caller holdscontent.create). An anonymous caller gets["read"].translationslists the readable versions of the story, the version being read included, withid,lang,title,is_online,language_enabledand their owncapabilities. A version without language (lang"") reportslanguage_enabled: true. The order is the default language of the site first, then the other tags in ascending order (en,ar,de,fr); a disabled language is still listed withlanguage_enabled: false. A single-version article lists itself. An anonymous caller gets[].sourceis{"article_id", "revision"}when the version was created by copying another version and that source still exists and is readable by the caller, otherwisenull(a version created empty, a deleted source, an unreadable source, an anonymous caller).source.revisionis the content revision of the source at the moment of the copy.
What raises revision
A PATCH raises revision by exactly 1 when it changes at least one stored value, whatever the number of fields, and not at all when it changes nothing. Publishing and unpublishing never raise it.
| Changed field | revision |
internal content revision |
|---|---|---|
title, body, excerpt, slug, cover_image_alt, seo_title, seo_description, social_title, social_description |
+1 | +1 |
author_ids, cover_image_id, social_image_id, canonical_url, noindex, seo_dismissed, site_id |
+1 | unchanged |
Existing rows start at revision 1. A story move raises each moved version by 1.
Editing: PATCH /v1/article/{id}
Verified session or API key with content.update on the version (403 missing_permission otherwise, checked first) and able to read it (404 Article not found otherwise, checked second), so a stale if_revision never reveals anything to a caller who cannot edit or read the version.
title: 3 to 200 runes (a 4-byte emoji counts as one)."", 2 and 201 runes answer400 Article title must be between 3 and 200 characters.;nullanswers400with the body-mismatch message. Editing the title never changes the slug and never changes the publication: an online version shows the new title publicly at once.slug: changed only when sent.""regenerates it from the current title. There is no redirect, the old address stops resolving. Unique per site and language: the same slug in two languages of a story is allowed. A slug already used by another article of the same site and language answers409 slug_takenwithX-Slug-Suggestion. Sending the current slug changes nothing.if_revision: optional integer>= 1. A value that is0, negative, a string, a fraction ornullanswers400(body mismatch).if_revisionalone is not an update (400). When it differs from the currentrevision, nothing is stored and the answer is409 revision_conflictwithX-Current-Revision, even when the rest of the request would change nothing. The compare and the write are one atomic step: of two concurrent saves with the sameif_revision, one answers200and the other409. Withoutif_revisionthe last write wins. To overwrite after a conflict, sendif_revisionequal to theX-Current-Revisionthe conflict showed; if somebody saved in between, the answer is409again with the newer revision.site_idon a version whose story holds several versions the caller can read answers409 story_move_requiredand changes nothing (hidden versions are not counted, so the answer never reveals them). When the caller reads only this version of a larger story,content.updateis required on every version of the story, readable or not (403 missing_permissionotherwise, naming no version) and the request runs the story move rules: every version moves in one transaction, language and slug refusals roll everything back, and headers name readable versions only. The other fields of the same request (title,author_ids,slug, SEO fields) are part of that transaction: a refusal of any of them, an unknown author or an invalid SEO value included, leaves every version on its original site and everyrevisionunchanged, and the edited version'srevisionrises by exactly 1 for the whole request. To move a story with several readable versions, use the story move. On a story with one version it moves the version when its language is enabled at the destination (422 language_not_enabled, withX-Missing-Languages, otherwise); a version without language moves without a language check. The slug rule of the destination still applies (409 slug_taken).lang,story_id,source_article_idandcopyin the body are ignored.
The 200 body is the full article object with the new revision.
Publishing
PATCH /v1/article/{id}/publish on an offline version whose language is disabled on its site answers 422 language_disabled. Publishing an already online version is idempotent (200, unchanged) even when its language is disabled; a version without language publishes as before. unpublish and PATCH stay available in a disabled language. Publishing or unpublishing one version never touches its siblings.
Creating a translation
Two entry points share one implementation: the same result, errors, headers and stored data.
POST /v1/article/{id}/translations
Verified session or API key with content.create; the version {id} must be readable by the caller.
{"lang": "fr", "title": "Couts de renovation", "copy": true, "source_article_id": 11}
| Field | Rule |
|---|---|
lang |
required; normalized (FR is stored as fr) |
title |
required, 3 to 200 characters |
copy |
required boolean (400 when absent) |
source_article_id |
optional, only with copy: true (400 with copy: false); default is {id}; must be a readable version of the same story |
The new version joins the story of {id}, in the same site, as an offline draft created by the caller, with revision 1. The slug is generated from the new title in (site, language) and gets the next free -N suffix when taken.
copy: truecopiesbody,excerpt,seo_title,seo_description,social_title,social_description,cover_image_id,cover_image_alt,social_image_idand the authors from the source, and neverslug,canonical_url(empty),noindex(false) orseo_dismissed(empty). No image or file row is duplicated. The source and its content revision are captured in the same transaction as the copy: the read shows"source": {"article_id": <source>, "revision": <content revision then>}.copy: falsestarts empty: empty text, no images,authorsis the creating user's byline,"source": null.
Answers 201 with the created article object. Order of checks: permission, path id, body, version readable, copy rules, language tag, setup_required, source, language configuration. A story deleted in the meantime answers 404 Article not found. Both entry points leave the stored lang normalized.
POST /v1/article/ with story_id
{"title": "Couts", "lang": "fr", "story_id": 5, "copy": true, "source_article_id": 10}
story_id selects the story (404 Article not found when it has no readable version, the same answer as a missing story) and the first readable version, oldest first, plays the role of {id}. lang is then required (400). source_article_id and copy are only valid with story_id (400 otherwise).
POST /v1/article/ without story_id
It creates a new story with one offline version, on the default site unless the body names a site.
{"title": "Neuf", "lang": "fr", "site": "atelier"}
siteis optional: the site key. Absent ornull, the default site is used exactly as before. A presentsiteis validated like any site key (400with the plain-text bodyinvalid_site_keyfor""orBad Key!), then looked up (404 site_not_foundfor an unknown key, a plain text body, nothing stored). A site deleted after that lookup and before the article is stored answers409 target_site_deleted(plain text, nothing stored). Every caller who may create articles may create on every site:content.createis not scoped to a site.langis optional. Absent, the version gets the starting language of the chosen site; present, it is normalized and must be an enabled language of the chosen site (422 invalid_language,unsupported_language,language_not_enabled;409 language_unavailablewhen disabled on that site;409 setup_requiredbefore that site's default language is confirmed). The same tag can therefore be accepted on one site and refused on another.- The slug is generated from the title and made free within the chosen site and language only.
sitewithstory_id,source_article_idorcopyanswers400with the body mismatch message and stores nothing.POST /v1/article/{id}/translationshas nositefield: the translation keeps the site of its source and an unknownsitekey in its body is ignored.- The response is the created article object (
201) with the same keys as before;site_idis the id of the chosen site. The article is offline, so it is invisible to anonymous reads and public lists, andGET /v1/article/?site=<key>lists it for readers. article.createrecordsparams.site(the site key) on success; a refusal records one failed event with a plain reason (refusals by validation,401and403happen before the use case and record nothing).
Deleting
DELETE /v1/article/{id} removes one version (204, empty body) and checks content.delete per version (403 missing_permission). Other versions and their story remain. Deleting the last version of a story removes the story. Nothing in the answer mentions a sibling. A deleted language can be created again in the same story; a language with a deleted version still counts as in use for removing the language from the site.
Moving a story: POST /v1/story/{story_id}/move
{"site": "atelier"}
Verified session or API key. The caller needs content.update on every version of the story, readable or not, and must read at least one (404 Article not found otherwise, also for an unknown story). site is the key of the destination (400 when absent or empty, 404 site_not_found when unknown). Answers 200:
{"story_id": 5, "site": "atelier", "site_id": 2, "article_ids": [10, 12, 11, 13]}
article_ids lists the readable versions only, oldest first. Every version moves in one transaction: site_id changes, slugs, languages, publication, content and authors are unchanged, revision of each moved version rises by 1 and its content revision does not. Moving to the current site answers 200 and changes nothing.
Everything is checked inside the transaction, under the locks of both sites, and nothing moves when something fails: every language of every version (readable or not) must be enabled at the destination (422 language_not_enabled, X-Missing-Languages limited to languages of readable versions, a language disabled at the destination counts as missing, a language disabled at the source does not matter), and no address may exist at the destination in the same language (409 slug_conflict, X-Conflicting-Articles limited to readable versions). A conflict that appears between the check and the write answers 409 slug_conflict without the header.
Editorial list by story: GET /v1/article/?group=story
An opt-in shape of the list for a signed-in, verified caller whose role resolves. Without group the response keeps the flat envelope { items, total, page, size, counts } of the paginated list, whose counts are all, online, draft and suggestions. A caller without a valid session, or with an unverified user, who sends group gets 400 with the body-mismatch message and nothing is read.
| Parameter | Meaning |
|---|---|
group |
Only story. group=, any other value, or group from a public caller is 400. |
lang |
A supported language tag, normalized like the language of a version (fr, pt-br becomes pt-BR). Empty, malformed or unsupported is 400. Also narrows the flat list (items, total, counts.all, counts.online, counts.draft). Disabled languages can be selected. |
tab |
all (default) or missing. review, an empty value, any other value, and missing without group=story are 400. |
author |
A byline id, an integer of at least 1; anything else is 400. Also narrows the flat list. |
page, size, q, status, site, seo, sort |
Same values, defaults and validation as the flat list. |
Only versions the caller can read count: they are titled, credited, listed in coverage, searched and counted. A hidden version never appears, and a story whose versions are all hidden has no row.
Rows. With no lang a row is a story: total counts stories. With lang a row is the version of that language (tab=all), and the whole story's coverage, story_authors and missing_langs still ride on the row. With tab=missing rows are stories again: lang=fr lists the stories on a site that enables fr and that have a readable version but none in fr; without lang it lists the stories that lack at least one enabled language, and a caller who cannot create (no content.create) gets total 0. Articles without language (lang "") are one story each and are never listed as missing a language. A version of the story must satisfy status, author, q and seo together to make the story match; q ignores case and accents.
Item. The article list item of the representative version (the one in lang, otherwise the site default language version, otherwise the first readable one by language tag), plus:
| Field | Type | Meaning |
|---|---|---|
story_updated_at |
RFC 3339 | The latest updated_at among the readable versions of the story |
story_authors |
array of bylines | The union of the bylines of the readable versions, without duplicates |
coverage |
array | One entry per readable version, in the order of translations (default language, other enabled tags, disabled tags): { id, lang, title, is_online, language_enabled, capabilities } |
missing_langs |
array of tags | Enabled languages without a readable version, default first then by tag; [] when the caller cannot create |
capabilities |
array | The capabilities of the representative version |
Sort. The whitelist is the flat one. updated_at uses the latest readable version of the story, created_at the earliest, title and status the representative; with lang the version itself. Ties end with story_id descending.
Counts. { all, online, draft, suggestions, missing }. They follow lang, q, author, seo and site but never status or tab, and count the unit of the mode (stories, or versions of the selected language). online and draft count the units with a readable online or draft version. missing is the total the not-in view would give under the same filters (0 without lang for a caller who cannot create). suggestions is the number of stories with a readable version whose seo_status is suggestions, ignoring every parameter.
A repository failure at any step, counts included, answers 500 Failed to retrieve articles.
Mock-ready examples
Statuses of the new and changed calls. Bodies are plain text unless shown as JSON; headers listed are the only extra ones.
| Call | Status | Headers | Body |
|---|---|---|---|
GET /v1/article/10 (see above) |
200 | article object with revision, capabilities, translations, source |
|
POST /v1/article/10/translations {"lang":"fr","title":"Couts","copy":true} |
201 | article object, lang "fr", "is_online": false, "revision": 1, "source": {"article_id": 10, "revision": 4} |
|
DELETE /v1/article/12 |
204 | empty | |
POST /v1/story/5/move {"site":"atelier"} |
200 | {"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]} |
|
POST /v1/article/10/translations {"lang":"fr","title":"Couts"} |
400 | The request cannot be processed due to a mismatch in the format of the body. |
|
PATCH /v1/article/10 {"title":"ab"} |
400 | Article title must be between 3 and 200 characters. |
|
POST /v1/article/10/translations with a cookie of an anonymous client |
401 | You must be logged in to access this resource. |
|
POST /v1/story/5/move by a user holding content.update at own |
403 | X-Missing-Permission: content.update |
missing_permission |
POST /v1/article/9999/translations |
404 | Article not found |
|
POST /v1/article/10/translations with "source_article_id":9999 |
404 | source_unavailable |
|
POST /v1/story/5/move {"site":"nowhere"} |
404 | site_not_found |
|
PATCH /v1/article/10 {"body":"x","if_revision":3} after somebody saved |
409 | X-Current-Revision: 4 |
revision_conflict |
POST /v1/article/10/translations {"lang":"de","title":"Kosten","copy":true} |
409 | X-Existing-Article-Id: 11 |
language_exists |
POST /v1/article/10/translations {"lang":"fr","title":"Couts","copy":true} with fr disabled, unreadable or raced |
409 | none | language_unavailable |
POST /v1/article/30/translations on an article without language |
409 | setup_required |
|
PATCH /v1/article/10 {"slug":"taken-en"} |
409 | X-Slug-Suggestion: taken-en-2 |
slug_taken |
POST /v1/story/5/move {"site":"atelier"} with an address taken at the destination |
409 | X-Conflicting-Articles: 12 |
slug_conflict |
PATCH /v1/article/10 {"site_id":2} on a story with several readable versions |
409 | story_move_required |
|
POST /v1/article/10/translations {"lang":"pt","title":"Custos","copy":true} with no pt row |
422 | language_not_enabled |
|
POST /v1/story/5/move {"site":"atelier"} with no ar at the destination |
422 | X-Missing-Languages: ar |
language_not_enabled |
PATCH /v1/article/15/publish with es disabled |
422 | language_disabled |
|
POST /v1/article/10/translations {"lang":"xx","title":"Couts","copy":true} |
422 | unsupported_language |
|
POST /v1/article/10/translations {"lang":"english","title":"Couts","copy":true} |
422 | invalid_language |
|
POST /v1/article/10/translations with "source_article_id":20 of another story |
422 | source_other_story |
Activity
No new action code. The existing events carry params.lang (the language of the version, omitted for a version without language): article.create, article.edit, article.publish, article.unpublish, article.delete. A translation creation records article.create on the new version with params.lang, params.story_id and params.source_article_id (only when copied). A story move records one article.edit per moved version, with a site change (site names before and after) and params.lang; moving to the current site records nothing. A refused creation or move records one failed event with a plain reason that names no version; refusals answered 404 are not recorded.
Public language routes
Contract for features/localization_public_api_and_surfaces.feature. Both routes are open to everyone: a jwt cookie or an API key is ignored, so a draft is never returned, whoever asks. They sit under /v1/public and are read only.
A version is published when is_online is true, and eligible when it is published and its language is enabled on the site. A disabled language stays readable on both routes; only the alternates leave it out.
Exact lookup: GET /v1/public/site/{key}/lang/{lang}/article/{slug}
Matches the site key, the language and the slug, and nothing else. It never falls back to another language, never follows an old slug and creates no redirect.
langis normalized (FRreadsfr,pt-brreadspt-BR). A value that is not a language tag (e,pt_BR) is400 invalid_language; a valid tag with no version, or outside the supported list, is404 article_not_found.formatbehaves as onGET /v1/article/{id}:markdown(default),htmlorjson; anything else is400withThe format must be one of: markdown, html, json.- An unassigned article (
lang""), a draft, a soft-deleted version and a version of another site or language are all404 article_not_found, with the same body. - Answers
200with the body of an anonymousGET /v1/article/{id}(same keys and values) and one more key,alternates.
{
"id": 12,
"lang": "fr",
"story_id": 5,
"slug": "comment-nous-chiffrons-une-renovation",
"is_online": true,
"alternates": [
{"id": 10, "lang": "en", "story_id": 5, "slug": "how-we-price-a-bathroom-renovation", "url": "https://journal.example.com/en/how-we-price-a-bathroom-renovation"},
{"id": 11, "lang": "de", "story_id": 5, "slug": "so-kalkulieren-wir-eine-badsanierung", "url": "https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung"}
]
}
alternates lists the other eligible versions of the story: the served version, drafts and versions in a disabled language are left out, and nothing in the response names them. The order is the default language of the site first, then the other tags in ascending order. Each alternate has exactly id, lang, story_id, slug and url. url is the site Public URL pattern filled with the tag and the slug, or null when the site has no pattern. A story with one version answers [].
Story resolution: GET /v1/public/site/{key}/story/{story_id}
Resolves one story of the site, optionally with an explicit fallback to the default language. No other language is ever tried.
| Query | Rule |
|---|---|
lang |
optional. Absent, the site default language is requested. A malformed tag is 400 invalid_language |
fallback |
optional, only the value default. When the requested language has no published version, serve the published default-language version instead. Any other value, an empty one included, is 400 with the body-mismatch message |
format |
as on the exact lookup |
The 200 body is the exact lookup body plus requested_lang (the language that was asked for, or the default when lang is absent) and fallback (true only when the default-language version replaced the requested one). lang is the language of the version served.
| Request | Answer |
|---|---|
?lang=fr, a published fr version exists |
the fr version, fallback false |
?lang=fr, none, no fallback |
404 language_not_available |
?lang=fr&fallback=default, fr missing or unpublished, en published |
the en version, requested_lang fr, fallback true |
?lang=fr&fallback=default, neither published |
404 language_not_available (a third language is never returned) |
?lang=en&fallback=default, en unpublished |
404 language_not_available |
no lang |
the default-language version, requested_lang the default |
no lang, the site has no confirmed default language |
404 language_not_available |
| a story of another site, an unknown story, a story with no published version | 404 language_not_available |
Changing the default language of a site changes only the requests that omit lang.
Errors
Plain text, the body is the code followed by a line feed. language_disabled is not returned by these routes.
| Status | Body | When |
|---|---|---|
| 400 | invalid_language |
lang is not a language tag |
| 400 | The request cannot be processed due to a mismatch in the format of the body. |
fallback is not default |
| 400 | The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). |
story_id is not an unsigned integer |
| 400 | The format must be one of: markdown, html, json. |
invalid format |
| 404 | article_not_found |
exact lookup: no published version for the site, language and slug |
| 404 | language_not_available |
story resolution: no version can be served under the rules above |
| 404 | site_not_found |
unknown site key |
| 500 | Internal server error |
a repository failure |
Resolved SEO and sitemap, language aware
GET /v1/article/{id}/seo keeps its keys and adds lang, alternates (same shape and order as the public alternates), og.locale_alternates and fallbacks.site_text_skipped. Every other key keeps its name and type. An unassigned article (lang "") answers lang "", alternates [] and og.locale_alternates [], with the legacy canonical and the raw site locale.
Canonical. canonical_url and og.url are, in order: the version canonical_url override (fallbacks.canonical canonical_url); the site public_url_pattern with {lang} and {slug} filled (pattern); the legacy base_url/slug (site), when the site has no pattern or the article has no language. A region tag is kept as written (/pt-BR/reforma). json_ld.mainEntityOfPage.@id equals canonical_url. Saving a pattern changes no override and no article.
og:locale, first match wins, never invented:
- the explicit
social_localeof the language row; - the tag already has a region (
pt-BRgivespt_BR); Site.Localehas the same language part and a region (en-GBwithengivesen_GB);- otherwise
"", andog.locale_alternatesis[].
og.locale_alternates lists the social locales of the other eligible versions that resolve one, in the order of the alternates. GET /v1/site/{key}/languages reports rule 3 as social_locale_state derived (not_set when Site.Locale is bare such as en).
Fallbacks in the language of the version. The title and description chains are unchanged except for the site text: default_description is treated as written in the current default language of the site, so it is used only by an unassigned article or a version in that language. For any other language the description stays empty, fallbacks.description is none and fallbacks.site_text_skipped is true. Changing the default language changes which versions may use the site text. title_template, site_name and the image chain stay shared; cover_image_alt stays per version.
Sitemap. GET /v1/site/{key}/sitemap lists every eligible version once (published, language enabled; unassigned published articles keep their legacy entry). Each item is {url, modified_at, noindex, lang}; url is the localized address when the site has a pattern, otherwise base_url/slug. X-Total-Count counts the listed items.
Media usage
GET /v1/media/{id}/usage article items gain story_id and lang ("" for an unassigned article, whose story_id is the stored value). There is one article item per version, ordered by id; author, site and entry items carry neither key. used_in on GET /v1/media/ counts a story once, so an image used by three versions of one story is one use. An unassigned article is its own group.