feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
+62
-13
@@ -74,8 +74,8 @@ never changes the response or the change itself.
|
||||
|
||||
Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
|
||||
`api_key` and `release` (id and name are the tag).
|
||||
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
|
||||
kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A
|
||||
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
|
||||
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
|
||||
change that leaves a value as it was (same body, same publish state, same file name, same display
|
||||
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
|
||||
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
|
||||
@@ -132,33 +132,82 @@ session." with a key). The role is checked before the body.
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. |
|
||||
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. |
|
||||
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
|
||||
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. |
|
||||
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
|
||||
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
|
||||
| `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. |
|
||||
|
||||
Errors are plain text whose body is exactly the token: 409 `site_key_taken`, 409 `site_has_articles`,
|
||||
403 `default_site` (checked before the articles), 400 `key_immutable`, 400 `invalid_site_url`, 400
|
||||
`invalid_locale`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
|
||||
`invalid_locale`, 400 `invalid_title_template`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
|
||||
number, the site itself or not a whole number) and 404 `site_not_found`. Refused writes except `site_not_found` and `image_not_found` are recorded as failed events. A body that cannot be
|
||||
decoded is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message, and a
|
||||
field that fails validation (`name`, `key` format, `default_robots`) is 400 with the validator text.
|
||||
field that fails validation (`name`, `key` format, `default_robots`, `site_name` over 100 characters, `title_template` over 200, `default_description` over 320) is 400 with the validator text.
|
||||
|
||||
A site also carries `site_name` (at most 100 characters, `""` by default), `title_template` (at most 200 characters, `%title% | %site%` by default; it must contain `%title%`, case sensitive, otherwise 400 `invalid_title_template`, an empty template included) and `default_description` (at most 320 characters, `""` by default). They appear on every site response, `POST` accepts them and `PATCH` edits them like the other site fields. Existing sites get the defaults when the server starts after the upgrade.
|
||||
|
||||
Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>`
|
||||
keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`;
|
||||
`counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the
|
||||
body-mismatch message. `PATCH /v1/article/{id}` takes `body` and `site_id` (an
|
||||
API key may send it too) and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
|
||||
those fields, a `null` value or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
|
||||
body-mismatch message. `PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` and the SEO
|
||||
fields below, and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
|
||||
those fields, a `null` value (except on `cover_image_id` and `social_image_id`) or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
|
||||
400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never
|
||||
changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400.
|
||||
|
||||
### Article SEO fields
|
||||
|
||||
An article carries twelve search and sharing fields, all edited through `PATCH /v1/article/{id}` and returned by
|
||||
every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`):
|
||||
`slug`, `excerpt`, `seo_title`, `seo_description`, `cover_image_id`, `cover_image_alt`, `social_title`,
|
||||
`social_description`, `social_image_id`, `canonical_url`, `noindex` and `seo_dismissed`. Text fields are `""`
|
||||
when empty, the two image ids are `null` when unset, `noindex` is `false` and `seo_dismissed` is `[]` by
|
||||
default. Each item of `GET /v1/article/` carries `excerpt` and `slug` and none of the other ten.
|
||||
The limits are counted in characters: `excerpt` 300, `seo_title` 120, `seo_description` 320,
|
||||
`cover_image_alt` 200, `social_title` 120, `social_description` 320, `canonical_url` 2048; going over is
|
||||
400 with the validator text. `cover_image_id` and `social_image_id` accept `null` (and only those two do);
|
||||
`0` is 400 with the validator text and an unknown image is 400 `image_not_found`. `canonical_url` is `""` or an absolute
|
||||
`https` URL with a host, otherwise 422 `invalid_url`. `seo_dismissed` is a list of the codes `title_long`,
|
||||
`title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`
|
||||
and `short`: an unknown code is 400 with the validator text and duplicates are collapsed with the order
|
||||
kept. `noindex` is only a flag, it does not take the article offline.
|
||||
|
||||
The `slug` matches `^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters (400 `invalid_slug`) and is unique per
|
||||
site, never globally. It is generated from the title when the article is created: lowercase, accents folded,
|
||||
every run of other characters becomes one dash, dashes trimmed, cut to 100 characters, and `article` when no
|
||||
letter or digit is left; a slug already used on the site gets `-2`, `-3`... silently. The same rule
|
||||
backfills the existing articles when the server starts after the upgrade (in id order within each site, so
|
||||
the oldest article keeps the plain slug). Sending `slug: ""` regenerates the slug from the title. A slug
|
||||
used by another article on the target site (the new site when `site_id` is sent too, and a move re-checks
|
||||
the current slug) is 409 with the plain-text body `slug_taken`; the response then carries the header
|
||||
`X-Slug-Suggestion` holding the first free `<slug>-N` from 2 on that site, fitting 100 characters. The header
|
||||
is set on this 409 only, never on another response. A regeneration that collides is refused the same way, never suffixed
|
||||
silently. Saving an article with its own slug is not a collision. Publishing never changes a slug, a deleted article
|
||||
frees its slug.
|
||||
|
||||
New error tokens are plain text from the handler: 409 `slug_taken`, 400 `invalid_slug`, 422 `invalid_url`, 400
|
||||
`image_not_found` and 400 `invalid_title_template` (sites). Edits that change a SEO field are recorded as
|
||||
`article.edit` events (`slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` with before and after;
|
||||
the free-text fields as a size delta only); a refused slug is recorded as failed.
|
||||
|
||||
A site's `default_social_image_id` counts as a use of that image: it raises `used_in` and keeps the
|
||||
image out of the `unused` filter, while `GET /v1/media/{id}/usage` keeps listing articles only.
|
||||
Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
|
||||
image out of the `unused` filter. Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
|
||||
/v1/media/purge` repoint the sites of the merged copies to the kept image and report it as
|
||||
`sites_updated` next to `articles_updated`.
|
||||
|
||||
An article's `cover_image_id` and `social_image_id` count the same way. `used_in` counts every article that uses the
|
||||
image in its body, as cover or as social image once, plus the authors using it as an avatar and the sites using it as default
|
||||
social image. Deleting an image never fails because of its uses: the covers and social images pointing at it are set to `null`
|
||||
(the `cover_image_alt` and the bodies are untouched), and a failed delete leaves every reference in place. A merge or a purge repoints
|
||||
the covers and social images of the copies to the kept image, and an article rewritten in its body and its cover counts once in
|
||||
`articles_updated`.
|
||||
|
||||
`GET /v1/media/{id}/usage` stays an array. Every item keeps the keys it had and gains `type` (`article`,
|
||||
`author` or `site`) and `fields`. An article item is `{ "type": "article", "id", "title", "published", "fields" }` with
|
||||
`fields` from `body`, `cover` and `social` (one item per article, in that order); an author item is
|
||||
`{ "type": "author", "id", "title": <name>, "fields": ["avatar"] }` and a site item is `{ "type": "site", "id", "title": <name>,
|
||||
"fields": ["default_social_image"] }`; author and site items have no `published` key. The array is `[]` for an unused image.
|
||||
|
||||
## Authors
|
||||
|
||||
An author is a public byline, with or without an account. It is `{ id, name, slug, bio, avatar_id,
|
||||
@@ -203,8 +252,8 @@ creator's linked author when there is one. `GET /v1/auth/me` returns the existin
|
||||
`social_links`, `article_count`) or `null`.
|
||||
|
||||
An author's `avatar_id` counts as a use of that image like a site's social image: it raises `used_in`
|
||||
and keeps the image out of the `unused` filter, while `GET /v1/media/{id}/usage` keeps listing articles
|
||||
only. Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge`
|
||||
and keeps the image out of the `unused` filter, and `GET /v1/media/{id}/usage` lists the author with the field `avatar`.
|
||||
Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge`
|
||||
repoint the avatars of the merged copies and report it as `authors_updated`.
|
||||
|
||||
## Image variants
|
||||
|
||||
Reference in new issue
Block a user