11 KiB
Site languages, language setup and public URL pattern
Contract for features/site_languages.feature, features/language_setup.feature and features/site_public_url_pattern.feature. The SPA mocks /v1 from this page.
Errors are plain text whose body is exactly the code, with the status below. A body that cannot be decoded, or that lacks a required key, is 400 with the body-mismatch message ("The request cannot be processed due to a mismatch in the format of the body."). An anonymous caller is 401, a verified user without settings.manage_sites is 403 missing_permission. Writes need a real session; API keys are refused.
An owner is a verified user holding settings.manage_sites. A reader is any other verified user.
Visibility exception (accepted)
version_count and published_count in GET /v1/site/{key}/languages are site-level aggregates of every non-deleted article (soft-deleted articles are not shown but still keep their language in use) of the language, drafts and offline ones included. They are visible to every signed-in reader, whatever that reader may open. This is a deliberate exception to the per-version visibility policy of docs/design/localization-tables.md ("Hidden versions"), accepted for slice 2. Later slices that expose titles or links of single versions must still filter by what the caller can read.
Languages of a site
The supported tags are the list of internal/domain/language (en, en-GB, en-US, fr, fr-CA, pt, pt-BR, es, de, ar). Tags are normalized: pt-br is stored as pt-BR.
GET /v1/site/{key}/languages
Any verified user (session or API key). 404 site_not_found.
{
"revision": "9f2c...",
"default": "en",
"needs_setup": false,
"languages": [
{"lang": "en", "enabled": true, "is_default": true, "version_count": 7, "published_count": 6, "social_locale": null, "social_locale_state": "not_set"},
{"lang": "fr", "enabled": true, "is_default": false, "version_count": 4, "published_count": 2, "social_locale": "fr_FR", "social_locale_state": "set"}
],
"available": [
{"lang": "pt", "substitute": null},
{"lang": "pt-PT", "substitute": "pt"}
]
}
revisionis an opaque digest of the rows (lang, default, enabled, social_locale). Counts do not change it. Send it back onPUT.languageslists the default first, then the other enabled languages by tag, then the disabled ones by tag.social_locale_stateisset(an explicitsocial_locale),derived(no explicit value and the tag has a region: the effective value is the tag with an underscore,en-GBgivesen_GB) ornot_set.social_localeis only the explicit value, so the client computes the derived one from the tag.availablelists the tags an owner can pick. A tag with a non-nullsubstituteis not supported itself; only the broader tag is. The server refuses it withunsupported_language, so the client sends the substitute once the user confirms.needs_setupis true when the site has no confirmed default language. Thendefaultisnull,languagesis[]andPUTanswers 409setup_required. Sites created after the migration have no language row and need setup too.
PUT /v1/site/{key}/languages
Owner only. The whole set is replaced in one transaction under the site lock.
{
"revision": "9f2c...",
"default": "en",
"languages": [
{"lang": "en", "enabled": true, "social_locale": null},
{"lang": "fr", "enabled": true, "social_locale": "fr_CA"}
]
}
revision, default, languages and each enabled are required (400 when absent). social_locale is optional and null means none. A language left out of the list is removed. The answer is 200 with the new read shape. Sending the state that is already stored changes nothing and keeps the revision.
Checks run in this order:
| Order | Status | Body | When |
|---|---|---|---|
| 1 | 404 | site_not_found |
unknown key |
| 2 | 409 | setup_required |
the site has no confirmed default (any body) |
| 3 | 409 | languages_changed |
revision is not the current one; nothing is applied |
| 4 | 422 | invalid_language |
a tag is not a language tag (english, empty) |
| 4 | 422 | unsupported_language |
a well formed tag that is not supported (xx, pt-PT) |
| 4 | 422 | duplicate_language |
the same tag twice, case ignored |
| 4 | 422 | invalid_social_locale |
not ll_RR or lll_RR (letters, one underscore), empty string, or the language part differs from the tag's language |
| 5 | 422 | no_enabled_language |
no language is enabled, languages: [] included |
| 6 | 422 | default_not_enabled |
default is not in the list, not enabled, or not a supported tag |
| 7 | 409 | language_in_use |
a removed language still has an article, draft, offline, online or soft-deleted (the display count version_count stays non-deleted only) |
| any | 409 | languages_changed |
the database could not lock the site in time because another change is running (lock contention); nothing is applied, reload and retry |
Entries are checked in list order for step 4. A rejected request changes nothing. Disabling a language never touches its articles, and no article changes lang, status or publication. A site always has exactly one default language: the rows are planned with one default and checked again before they are written. Changing the default language re-evaluates the stored SEO status of every article of the site, because the site default_description only counts for the default language (see SEO status and suggestions). A social_locale is stored as language_REGION (fr_fr becomes fr_FR). The language part rule only compares with the tag; matching Site.Locale is deferred to slice 6.
First-time language setup
One global setup for every site with no confirmed default language. Owner only (GET as well: a reader is 403).
GET /v1/language/setup
{
"sites": [
{"key": "journal", "name": "Journal", "locale": "en", "article_count": 8, "suggestion": {"lang": "en", "substituted": false}},
{"key": "notes", "name": "Notes", "locale": null, "article_count": 3, "suggestion": null}
],
"available": [{"lang": "en", "substitute": null}]
}
sites lists the pending sites by id. suggestion comes from Site.Locale and keeps the region (pt-BR stays pt-BR); substituted is true when only the broader tag is supported (fr-BE gives fr). suggestion is null without a locale or without a supported language. An empty sites list means nothing is pending.
POST /v1/language/setup
{"sites": [{"key": "journal", "lang": "en"}, {"key": "notes", "lang": "en"}]}
200 with {"assigned": 16, "sites": [{"key": "journal", "lang": "en", "assigned": 8}]}. assigned counts the articles that got a language (soft-deleted ones included). sites lists the sites confirmed by this call. The default language is created enabled and confirmed, the seeded unconfirmed row is replaced, and every article without a language takes the tag. Nothing is translated, linked or moved. Sites that already have a confirmed default are skipped, whatever tag is asked for, so a retry answers 200 with assigned: 0.
| Status | Body | When |
|---|---|---|
| 400 | body-mismatch message | no sites key or malformed JSON |
| 404 | site_not_found |
a key does not exist |
| 409 | setup_in_progress |
another setup holds the site locks (nothing applied) |
| 422 | invalid_language / unsupported_language |
a bad lang, empty included |
| 422 | duplicate_site |
the same key twice |
| 422 | setup_incomplete |
a pending site is missing from the request |
| 500 | Internal server error |
a write failed; everything is rolled back |
All sites are applied in one transaction. Site rows are locked in ascending id order without waiting; a busy lock is setup_in_progress. The pending list is read again after the locks and any site that became pending in between is locked too, until the list is stable, so a site created while a setup starts is never left half handled.
The 200 body also has skipped: the keys, sorted, of requested sites that already had a confirmed default and were left untouched (always present, [] when none). Their requested lang is ignored, never applied.
{"assigned": 3, "sites": [{"key": "notes", "lang": "fr", "assigned": 3}], "skipped": ["journal"]}
Lock contention
Every locking path of PUT /v1/site/{key}/languages and POST /v1/language/setup runs inside the transactor. A lock that cannot be taken in time (SQLite SQLITE_BUSY/SQLITE_LOCKED, MySQL 3572 and 1205, PostgreSQL 55P03) is recognised once, by the database driver, and reaches the use case as transaction.ErrLockContention. The PUT answers 409 languages_changed and the setup answers 409 setup_in_progress. Nothing is applied and a retry is safe. It is never a 500.
SQLite does not lock rows
The pinned SQLite driver (github.com/glebarez/sqlite) drops the FOR UPDATE clause: the SELECT ... FOR UPDATE [NOWAIT] that locks a site row on MySQL and PostgreSQL is a plain read on SQLite. SQLite instead serializes writers on the whole database: a second writer waits up to the 5 s busy timeout and a transaction that read before another one committed fails with SQLITE_BUSY when it tries to write. Both outcomes keep the data consistent and the busy failure is answered as lock contention above. test/localization_concurrency_test.go pins the statuses (200 or 409, never 500) for a PUT racing a site creation and for two simultaneous setups.
Activity log
Both writes are recorded in the sites category, once after the commit and never for a read or a no-op:
| Action | Target | Changes and params |
|---|---|---|
site.languages_edit |
the site | default_language (before, after) when the default changed, languages (before, after: tags sorted, (off) for disabled, [social_locale]). A PUT that changes nothing records nothing. |
site.language_setup |
one event per site confirmed by the POST | default_language ("" to the tag), param assigned (articles that got the language) |
A refused call is recorded as a failed event with a plain reason (for example "The languages changed since they were read."). An unknown site key is not recorded.
Public URL pattern
public_url_pattern is a string or null on every site JSON (GET /v1/site/, POST, PATCH). It is read with the existing list and written with the existing PATCH /v1/site/{id} (owner). No new route. A PATCH without the key leaves it unchanged; null or "" unsets it; surrounding spaces are trimmed.
Validation, in this order, answers 422 with the code as plain text: invalid_pattern (more than 512 characters), invalid_scheme (not https:// or http://), invalid_host (no host, for example https:///{slug}), unknown_placeholder (anything between braces other than {lang} and {slug}, or a stray brace), not_encoded (a space, a non-ASCII character or other character that needs URL encoding; %XX sequences are fine), missing_slug (no {slug}). Repeated placeholders are allowed. Saving the pattern changes no article, canonical URL or public response, and is recorded in the activity log as the public_url_pattern field.