feat: public API, SEO and existing surfaces

This commit is contained in:
Florian Sylvain committed 2026-11-03 03:58:10 +01:00
1 parent 45fbbc33b1
commit c390fcfe8d
103 files changed
+4476 -231

No files matched your search

+94
View File
@@ -237,3 +237,97 @@ Statuses of the new and changed calls. Bodies are plain text unless shown as JSO
## 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.
- `lang` is normalized (`FR` reads `fr`, `pt-br` reads `pt-BR`). A value that is not a language tag (`e`, `pt_BR`) is `400 invalid_language`; a valid tag with no version, or outside the supported list, is `404 article_not_found`.
- `format` behaves as on `GET /v1/article/{id}`: `markdown` (default), `html` or `json`; anything else is `400` with `The 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 all `404 article_not_found`, with the same body.
- Answers `200` with the body of an anonymous `GET /v1/article/{id}` (same keys and values) and one more key, `alternates`.
```json
{
"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:
1. the explicit `social_locale` of the language row;
2. the tag already has a region (`pt-BR` gives `pt_BR`);
3. `Site.Locale` has the same language part and a region (`en-GB` with `en` gives `en_GB`);
4. otherwise `""`, and `og.locale_alternates` is `[]`.
`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.