Files
RenewCMS/docs/api/site-languages.md
T

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"}
  ]
}
  • revision is an opaque digest of the rows (lang, default, enabled, social_locale). Counts do not change it. Send it back on PUT.
  • languages lists the default first, then the other enabled languages by tag, then the disabled ones by tag.
  • social_locale_state is set (an explicit social_locale), derived (no explicit value and the tag has a region: the effective value is the tag with an underscore, en-GB gives en_GB) or not_set. social_locale is only the explicit value, so the client computes the derived one from the tag.
  • available lists the tags an owner can pick. A tag with a non-null substitute is not supported itself; only the broader tag is. The server refuses it with unsupported_language, so the client sends the substitute once the user confirms.
  • needs_setup is true when the site has no confirmed default language. Then default is null, languages is [] and PUT answers 409 setup_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 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.