feat: public API, SEO and existing surfaces
This commit is contained in:
1 parent
45fbbc33b1
commit
c390fcfe8d
103 files changed
+4476
-231
No files matched your search
+5
-4
@@ -10,8 +10,9 @@ The origin that serves the app, for example `https://cms.example`. Every path st
|
||||
for example `https://cms.example/v1/article/`.
|
||||
|
||||
## Authentication
|
||||
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
|
||||
and `GET /v1/site/{key}/sitemap`. They return online articles, plus the drafts the caller's role may read
|
||||
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`,
|
||||
`GET /v1/site/{key}/sitemap`, `GET /v1/public/site/{key}/lang/{lang}/article/{slug}` and
|
||||
`GET /v1/public/site/{key}/story/{story_id}` (the two language routes ignore any credential and never return a draft, see [api/article-localization.md](api/article-localization.md#public-language-routes)). They return online articles, plus the drafts the caller's role may read
|
||||
(see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`.
|
||||
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
|
||||
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
|
||||
@@ -173,9 +174,9 @@ RenewCMS renders no page and injects no meta tag: it returns the data a frontend
|
||||
|
||||
| Route | Who | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads the drafts its role may read | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. |
|
||||
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads the drafts its role may read | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld, lang, alternates }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. |
|
||||
| `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `<slug>-N` in `suggestion`, never a 409. |
|
||||
| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex }]` of the online articles of the site with that key, by id ascending. `url` is `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. |
|
||||
| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex, lang }]` of the eligible versions (published, language enabled) of the site with that key, by id ascending. `url` is the localized address when the site has a Public URL pattern, otherwise `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. |
|
||||
|
||||
`GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -132,20 +132,20 @@ Publishing, unpublishing and `noindex` never change a slug. A deleted article fr
|
||||
|
||||
`GET /v1/article/{id}/seo` answers the data a frontend renders in its `<head>`. Online articles are public; a draft needs a verified session or API key whose role may read it (see [Content permissions](../api.md#content-permissions)) and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys:
|
||||
|
||||
`title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` and `json_ld` (a ready schema.org `Article`).
|
||||
`title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`, `locale_alternates`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` (`title`, `description`, `image`, `canonical`, `site_text_skipped`), `json_ld` (a ready schema.org `Article`), `lang` and `alternates`. The language rules (canonical from the Public URL pattern, `og:locale` order, site text only in the default language) are in [article-localization.md](article-localization.md#resolved-seo-and-sitemap-language-aware).
|
||||
|
||||
The fallback chain, first non-empty wins:
|
||||
|
||||
| Output | Chain | `fallbacks` value |
|
||||
| --- | --- | --- |
|
||||
| `title` | `seo_title`, then the article title, then the site `title_template` (`%title%`, `%site%` replaced by `site_name`, result trimmed) | `seo_title`, `title` |
|
||||
| `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` | `seo_description`, `excerpt`, `body`, `site`, `none` |
|
||||
| `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` (only for a version in the default language or without language) | `seo_description`, `excerpt`, `body`, `site`, `none` |
|
||||
| `image` | `social_image_id`, `cover_image_id` (with `cover_image_alt`), the site `default_social_image_id`; `url` is the site `base_url` plus the image path, `""` and `none` when there is none | `social`, `cover`, `site`, `none` |
|
||||
| `canonical_url` | `canonical_url`, then site `base_url` + `/` + `slug` | `canonical_url`, `site` |
|
||||
| `canonical_url` | `canonical_url`, then the site `public_url_pattern` filled with `lang` and `slug`, then site `base_url` + `/` + `slug` (no pattern, or no language) | `canonical_url`, `pattern`, `site` |
|
||||
| `robots` | `noindex, nofollow` when `noindex` is true, otherwise from the site `default_robots` (`index, follow` or `noindex, nofollow`) | none |
|
||||
| `og` and `twitter` title and description | `social_title` and `social_description`, then the resolved values | none |
|
||||
|
||||
`og.type` is `article`, `og.locale` is the site locale (`""` when none), `twitter.card` is `summary_large_image` with an image and `summary` without. `published_at` is the creation time of an online article and `null` for a draft; `modified_at` is `updated_at`. `authors[].url` is the author website (`""` when none) and `same_as` lists the social link URLs only. `json_ld.headline` is the meta title without the site template, and `author`, `image` and `datePublished` are left out when empty.
|
||||
`og.type` is `article`, `og.locale` is the explicit social locale of the language, else the region of the tag, else the region of the site locale with the same language, else `""`; an article without language keeps the raw site locale, `twitter.card` is `summary_large_image` with an image and `summary` without. `published_at` is the creation time of an online article and `null` for a draft; `modified_at` is `updated_at`. `authors[].url` is the author website (`""` when none) and `same_as` lists the social link URLs only. `json_ld.headline` is the meta title without the site template, and `author`, `image` and `datePublished` are left out when empty.
|
||||
|
||||
## SEO status and suggestions
|
||||
|
||||
|
||||
@@ -66,3 +66,9 @@ Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop.
|
||||
## Permissions follow the key's creator
|
||||
|
||||
A key acts as the user who made it. A key made by a user who is neither owner nor admin cannot do what only owners and admins can, and it answers `403 Only owners and admins can do this.` Check `GET /v1/auth/me` to read the role of the key.
|
||||
|
||||
## Languages
|
||||
|
||||
- Every article version has a `lang` and a `story_id`; the versions of one article share the `story_id`. List with `lang=fr` to filter a language, and the MCP `list_articles` tool takes `lang`.
|
||||
- `GET /v1/public/site/{key}/lang/{lang}/article/{slug}` and `GET /v1/public/site/{key}/story/{story_id}` return only published versions, with `alternates`. Fallback is never silent: ask for it with `fallback=default`, and only the default language is tried.
|
||||
- `GET /v1/article/{id}/seo` adds `lang`, `alternates`, `og.locale_alternates` and `fallbacks.site_text_skipped`. The canonical follows the site Public URL pattern unless the version overrides it.
|
||||
@@ -79,3 +79,14 @@ curl "{{BASE_URL}}/v1/article/1?format=markdown"
|
||||
```
|
||||
|
||||
Before step 4 this answers `404` with the text `Article not found`, because anonymous callers only see online articles.
|
||||
|
||||
## Read a language version (public)
|
||||
|
||||
Two public routes, no credential needed (a cookie or key is ignored and a draft is never returned):
|
||||
|
||||
```
|
||||
GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
|
||||
GET /v1/public/site/journal/story/5?lang=fr&fallback=default
|
||||
```
|
||||
|
||||
The first matches site, language and slug exactly and never falls back (`404 article_not_found`). The second resolves a story: without `lang` it asks for the site default language, and `fallback=default` serves the default-language version when the requested one is not published (`fallback: true`, `requested_lang` keeps the asked language); otherwise `404 language_not_available`. Both answer the article object plus `alternates`, the other published versions of the story in enabled languages (`id`, `lang`, `story_id`, `slug`, `url`; `url` is `null` when the site has no Public URL pattern), default language first, then by tag. An invalid `lang` is `400 invalid_language`.
|
||||
@@ -20,3 +20,7 @@ All of them are public. `{{VERSION}}` and `{{BASE_URL}}` in the sources are repl
|
||||
- Recipes in `docs/llm/` hold real responses. Re-capture them when a response shape changes.
|
||||
|
||||
`TestRouterRoutesAreInTheCatalogue` fails when a route of `api/router.go` is missing from the catalogue. Routes still undocumented are listed in `routesNotYetInTheCatalogue` in `test/api_catalogue_drift_test.go`; remove an entry once the route is documented.
|
||||
|
||||
## Languages in the generated output
|
||||
|
||||
The article object carries `lang` and `story_id`, and the public language routes `GET /v1/public/site/{key}/lang/{lang}/article/{slug}` and `GET /v1/public/site/{key}/story/{story_id}` return `alternates` and, for the second, `requested_lang` and `fallback`. They are described in `docs/api/article-localization.md`, in the catalogue and in the recipes, so `/llms-full.txt` and `/openapi.json` include them after `task build:llms`.
|
||||
Reference in new issue
Block a user